diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6a0ff55 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,104 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ + +# Virtual environments +venv/ +env/ +ENV/ +.venv/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Git +.git/ +.gitignore +.github/ + +# Docker +.dockerignore +Dockerfile* +docker-compose*.yml + +# Project specific - LARGE DIRECTORIES +data/ +mlruns/ +site/ +all_code_and_configs.txt +arla_monorepo_workspace.egg-info/ +logs/ +*.log +*.db +*.sqlite + +# Rust build artifacts +**/target/ +**/*.so +Cargo.lock + +# Documentation +docs/ +*.md +!README.md # Keep README.md as it's needed by Poetry + +# Environment files +.env* +!.env.example + +# Notebooks +*.ipynb +.ipynb_checkpoints/ + +# Testing +coverage.xml +*.cover +.hypothesis/ + +# Package managers +poetry.lock.bak +pip-log.txt + +# OS files +Thumbs.db +.DS_Store + +# Temporary files +*.tmp +*.bak +*.swp +*~ + +# Archives +*.tar +*.tar.gz +*.zip +*.gz + +# Media files (if you have any) +*.mp4 +*.avi +*.mov +*.jpg +*.jpeg +*.png +*.gif +!docs/**/*.png +!docs/**/*.jpg diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 023db5d..a5a8d2e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -50,4 +50,4 @@ jobs: run: poetry install - name: "Build and deploy documentation" - run: poetry run mkdocs gh-deploy --force \ No newline at end of file + run: poetry run mkdocs gh-deploy --force diff --git a/Dockerfile b/Dockerfile index 034d967..85d4432 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,69 @@ -# Build stage -FROM python:3.11.9-slim as builder - -ENV POETRY_HOME="/opt/poetry" -ENV POETRY_NO_INTERACTION=1 -ENV POETRY_VIRTUALENVS_CREATE=false -ENV PATH="$POETRY_HOME/bin:$PATH" - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - git \ - graphviz-dev \ - libgraphviz-dev \ - pkg-config \ - curl \ - && curl -sSL https://install.python-poetry.org | python - +# ============================================================================== +# ARLA Project Dockerfile - Optimized for Development Speed +# ============================================================================== -WORKDIR /app -COPY . . -RUN poetry install --without dev - -# Runtime stage FROM python:3.11.9-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - graphviz \ - gifsicle \ +# Set environment variables for Poetry +ENV POETRY_HOME="/opt/poetry" \ + POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_CREATE=true \ + POETRY_VIRTUALENVS_IN_PROJECT=true \ + POETRY_CACHE_DIR=/opt/poetry-cache \ + PATH="$POETRY_HOME/bin:$PATH" + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + git \ + graphviz \ + graphviz-dev \ + libgraphviz-dev \ + pkg-config \ + curl \ + gifsicle \ + && curl -sSL https://install.python-poetry.org | python - \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# Set working directory WORKDIR /app -COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ -COPY --from=builder /usr/local/bin/ /usr/local/bin/ -COPY --from=builder /app . +# Create the full directory structure that Poetry expects +RUN mkdir -p \ + /app/agent-core/src/agent_core \ + /app/agent-engine/src/agent_engine \ + /app/agent-sim/src/agent_sim \ + /app/agent-concurrent/src/agent_concurrent \ + /app/agent-persist/src/agent_persist \ + /app/simulations \ + /app/data \ + /app/mlruns + +# Copy all pyproject.toml files first for dependency resolution +COPY pyproject.toml poetry.lock* ./ +COPY agent-core/pyproject.toml ./agent-core/pyproject.toml +COPY agent-engine/pyproject.toml ./agent-engine/pyproject.toml +COPY agent-sim/pyproject.toml ./agent-sim/pyproject.toml +COPY agent-concurrent/pyproject.toml ./agent-concurrent/pyproject.toml +COPY agent-persist/pyproject.toml ./agent-persist/pyproject.toml + +# Create minimal __init__.py files so packages can be found +RUN touch /app/agent-core/src/agent_core/__init__.py && \ + touch /app/agent-engine/src/agent_engine/__init__.py && \ + touch /app/agent-sim/src/agent_sim/__init__.py && \ + touch /app/agent-concurrent/src/agent_concurrent/__init__.py && \ + touch /app/agent-persist/src/agent_persist/__init__.py + +# Install all dependencies including local packages in editable mode +# This will work now because the directory structure exists +RUN $POETRY_HOME/bin/poetry install --without dev --no-root && \ + $POETRY_HOME/bin/poetry run pip install -e ./agent-core -e ./agent-engine -e ./agent-sim -e ./agent-concurrent -e ./agent-persist && \ + rm -rf $POETRY_CACHE_DIR + +# Set Python path +ENV PYTHONPATH="/app/agent-core/src:/app/agent-engine/src:/app/agent-sim/src:/app/agent-concurrent/src:/app/agent-persist/src:/app/simulations" + +# Default command - keep container running CMD ["tail", "-f", "/dev/null"] diff --git a/Makefile b/Makefile index 84ef660..221eb92 100644 --- a/Makefile +++ b/Makefile @@ -23,18 +23,47 @@ RENDER_DIR ?= data/gif_renders FPS ?= 15 WORKERS ?= 4 +# --- Build Configuration --- +# Enable Docker BuildKit for better caching and parallel builds +export DOCKER_BUILDKIT=1 +export COMPOSE_DOCKER_CLI_BUILD=1 # --- Core Docker Commands --- ## up: Build images, start services, and initialize the database. up: @echo "πŸš€ Building images and starting all services..." - @docker compose up -d --build - @echo "⏳ Waiting 10 seconds for services to stabilize..." - @sleep 10 + @echo " Using BuildKit for optimized caching..." + @DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose up -d --build + @echo "⏳ Waiting for services to be healthy..." + @docker compose exec -T db sh -c 'until pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB; do sleep 1; done' || true + @docker compose exec -T redis sh -c 'until redis-cli ping; do sleep 1; done' || true @echo "πŸ”‘ Initializing database tables..." @make init-db @echo "βœ… All services are up and the database is initialized." + @echo "" + @echo "πŸ“Š Service Status:" + @docker compose ps + @echo "" + @echo "πŸ”— Available endpoints:" + @echo " - Application shell: docker compose exec app bash" + @echo " - MLflow UI: http://localhost:5001 (admin/password)" + @echo " - Dozzle logs: http://localhost:9999" + @echo " - PostgreSQL: localhost:5432" + @echo " - Redis: localhost:6379" + +## up-rebuild: Force rebuild all images from scratch (ignores cache). +up-rebuild: + @echo "πŸ”¨ Force rebuilding all images from scratch..." + @DOCKER_BUILDKIT=1 docker compose build --no-cache + @echo "πŸš€ Starting services with fresh images..." + @docker compose up -d + @echo "⏳ Waiting for services to be healthy..." + @docker compose exec -T db sh -c 'until pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB; do sleep 1; done' || true + @docker compose exec -T redis sh -c 'until redis-cli ping; do sleep 1; done' || true + @echo "πŸ”‘ Initializing database tables..." + @make init-db + @echo "βœ… All services are up with fresh builds." ## down: Stop and remove all containers, networks, and volumes. down: @@ -49,7 +78,7 @@ logs: ## init-db: Connects to the DB and creates all necessary tables. init-db: @echo "Initializing database and creating tables..." - @docker compose exec app poetry run python -m agent_sim.infrastructure.database.init_db + @docker compose exec app /opt/poetry/bin/poetry run python -m agent_sim.infrastructure.database.init_db ## setup: Create the .env file from the example template. setup: @@ -67,7 +96,7 @@ run: @sleep 5 @echo "▢️ Submitting experiment from: $(FILE)" # This command will now succeed because the 'app' container is running. - @docker compose exec app poetry run agentsim run-experiment $(FILE) + @docker compose exec app /opt/poetry/bin/poetry run agentsim run-experiment $(FILE) ## run-local: Run a single, local simulation for quick testing and debugging. run-local: diff --git a/agent-sim/src/agent_sim/infrastructure/tasks/celery_app.py b/agent-sim/src/agent_sim/infrastructure/tasks/celery_app.py index 8cc2169..1151285 100644 --- a/agent-sim/src/agent_sim/infrastructure/tasks/celery_app.py +++ b/agent-sim/src/agent_sim/infrastructure/tasks/celery_app.py @@ -23,7 +23,6 @@ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") -# --- MODIFIED: Explicitly list all modules containing tasks --- app = Celery( "agent_sim", include=[ @@ -32,9 +31,9 @@ # which helps resolve import paths when the task is executed. "simulations.berry_sim.run", "simulations.schelling_sim.run", + "simulations.tragedy_of_the_commons.run", ], ) -# --- END MODIFICATION --- app.conf.worker_pool_restarts = True app.conf.worker_pool = "prefork" diff --git a/docker-compose.yml b/docker-compose.yml index 2705268..6aac277 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,49 @@ +# ============================================================================== +# ARLA Project Docker Compose - Optimized for Development +# ============================================================================== + services: - # The Application Service + # Main application service app: build: context: . dockerfile: Dockerfile + args: + BUILDKIT_INLINE_CACHE: 1 volumes: - - .:/app + # Source code volumes for live development + - ./agent-core/src:/app/agent-core/src:rw + - ./agent-engine/src:/app/agent-engine/src:rw + - ./agent-sim/src:/app/agent-sim/src:rw + - ./agent-concurrent/src:/app/agent-concurrent/src:rw + - ./agent-persist/src:/app/agent-persist/src:rw + - ./simulations:/app/simulations:rw + + # Configuration and dependency files + - ./pyproject.toml:/app/pyproject.toml:ro + - ./poetry.lock:/app/poetry.lock:ro + - ./agent-core/pyproject.toml:/app/agent-core/pyproject.toml:ro + - ./agent-engine/pyproject.toml:/app/agent-engine/pyproject.toml:ro + - ./agent-sim/pyproject.toml:/app/agent-sim/pyproject.toml:ro + - ./agent-concurrent/pyproject.toml:/app/agent-concurrent/pyproject.toml:ro + - ./agent-persist/pyproject.toml:/app/agent-persist/pyproject.toml:ro + + # Data and output directories + - ./data:/app/data:rw + - ./mlruns:/app/mlruns:rw + + # Scripts and utilities + - ./scripts:/app/scripts:rw + + # Preserve virtual environment and cache - poetry_venv:/app/.venv + - poetry_cache:/opt/poetry-cache env_file: - .env environment: - PYTHONPATH: /app/agent-core/src:/app/agent-engine/src:/app/agent-sim/src:/app/agent-concurrent/src:/app/agent-persist/src:/app/simulations + PYTHONPATH: "/app/agent-core/src:/app/agent-engine/src:/app/agent-sim/src:/app/agent-concurrent/src:/app/agent-persist/src:/app/simulations" + PYTHONDONTWRITEBYTECODE: 1 + PYTHONUNBUFFERED: 1 depends_on: db: condition: service_healthy @@ -18,21 +51,52 @@ services: condition: service_healthy mlflow: condition: service_healthy - command: tail -f /dev/null + networks: + - arla_network + healthcheck: + test: ["CMD", "python", "-c", "import agent_sim; print('OK')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s - # The Worker Service + # Celery worker service worker: build: context: . dockerfile: Dockerfile - command: poetry run agentsim start-worker -Q "experiments,simulations" -c 4 - env_file: - - .env + args: + BUILDKIT_INLINE_CACHE: 1 + command: > + sh -c " + echo 'Starting Celery worker...' && + /opt/poetry/bin/poetry run agentsim start-worker -Q 'experiments,simulations' -c 4 + " volumes: - - .:/app + # Same volumes as app for consistency + - ./agent-core/src:/app/agent-core/src:rw + - ./agent-engine/src:/app/agent-engine/src:rw + - ./agent-sim/src:/app/agent-sim/src:rw + - ./agent-concurrent/src:/app/agent-concurrent/src:rw + - ./agent-persist/src:/app/agent-persist/src:rw + - ./simulations:/app/simulations:rw + - ./pyproject.toml:/app/pyproject.toml:ro + - ./poetry.lock:/app/poetry.lock:ro + - ./agent-core/pyproject.toml:/app/agent-core/pyproject.toml:ro + - ./agent-engine/pyproject.toml:/app/agent-engine/pyproject.toml:ro + - ./agent-sim/pyproject.toml:/app/agent-sim/pyproject.toml:ro + - ./agent-concurrent/pyproject.toml:/app/agent-concurrent/pyproject.toml:ro + - ./agent-persist/pyproject.toml:/app/agent-persist/pyproject.toml:ro + - ./data:/app/data:rw + - ./mlruns:/app/mlruns:rw - poetry_venv:/app/.venv + - poetry_cache:/opt/poetry-cache + env_file: + - .env environment: - PYTHONPATH: /app/agent-core/src:/app/agent-engine/src:/app/agent-sim/src:/app/agent-concurrent/src:/app/agent-persist/src:/app/simulations + PYTHONPATH: "/app/agent-core/src:/app/agent-engine/src:/app/agent-sim/src:/app/agent-concurrent/src:/app/agent-persist/src:/app/simulations" + PYTHONDONTWRITEBYTECODE: 1 + PYTHONUNBUFFERED: 1 depends_on: db: condition: service_healthy @@ -40,9 +104,17 @@ services: condition: service_healthy mlflow: condition: service_healthy + networks: + - arla_network restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import agent_sim; print('OK')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s - # The PostgreSQL Database Service + # PostgreSQL database db: image: postgres:15-alpine volumes: @@ -50,82 +122,133 @@ services: env_file: - .env ports: - - "5432:5432" - command: postgres -c 'max_connections=400' + - "${POSTGRES_PORT:-5432}:5432" + command: > + postgres + -c max_connections=400 + -c shared_buffers=128MB + -c effective_cache_size=512MB + -c maintenance_work_mem=64MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=16MB + -c default_statistics_target=100 + -c random_page_cost=1.1 + networks: + - arla_network healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-arla}"] interval: 10s timeout: 5s retries: 5 + start_period: 30s - # The Redis Service + # Redis cache and message broker redis: image: redis:7.2-alpine ports: - - "6379:6379" + - "${REDIS_PORT:-6379}:6379" volumes: - redis_data:/data + command: > + redis-server + --appendonly yes + --appendfsync everysec + --maxmemory 256mb + --maxmemory-policy allkeys-lru + networks: + - arla_network healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 + start_period: 10s - # The MLflow Service + # MLflow experiment tracking mlflow: image: ghcr.io/mlflow/mlflow:v2.14.1 ports: - - "5001:5000" + - "${MLFLOW_PORT:-5001}:5000" volumes: - - ./mlruns:/mlruns + - ./mlruns:/mlruns:rw env_file: - .env - depends_on: - db: - condition: service_healthy environment: - - MLFLOW_AUTH_CONFIG_PATH=/auth_config.ini + MLFLOW_AUTH_CONFIG_PATH: /auth_config.ini command: - bash - -c - | - # Install curl and PostgreSQL driver for auth database - apt-get update && apt-get install -y curl libpq-dev && - pip install psycopg2-binary && + set -e + echo "Installing dependencies..." + apt-get update && apt-get install -y --no-install-recommends curl libpq-dev + pip install --no-cache-dir psycopg2-binary - # Create auth config with PostgreSQL connection - cat > /auth_config.ini << EOF + echo "Creating auth config..." + cat > /auth_config.ini << 'EOF' [mlflow] admin_username = admin admin_password = password default_permission = READ - database_uri = postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + database_uri = postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-arla} EOF - # Start MLflow with authentication + echo "Starting MLflow server..." exec mlflow server \ --host 0.0.0.0 \ --port 5000 \ --backend-store-uri file:///mlruns \ - --app-name basic-auth + --app-name basic-auth \ + --serve-artifacts + depends_on: + db: + condition: service_healthy + networks: + - arla_network healthcheck: - test: ["CMD", "curl", "-f", "-u", "admin:password", "http://localhost:5000/"] - interval: 15s + test: ["CMD", "curl", "-f", "-u", "admin:password", "http://localhost:5000/health"] + interval: 30s timeout: 10s retries: 5 - start_period: 45s + start_period: 60s restart: unless-stopped - log_viewer: + # Container log viewer + dozzle: image: amir20/dozzle:latest - container_name: dozzle + container_name: arla_dozzle volumes: - - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock:ro ports: - - "9999:8080" + - "${DOZZLE_PORT:-9999}:8080" + environment: + DOZZLE_LEVEL: info + DOZZLE_TAILSIZE: 300 + DOZZLE_FILTER: "name=arla" + networks: + - arla_network restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080"] + interval: 30s + timeout: 10s + retries: 3 +# Named volumes for data persistence volumes: postgres_data: + driver: local redis_data: + driver: local poetry_venv: + driver: local + poetry_cache: + driver: local + +# Custom network for service communication +networks: + arla_network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 diff --git a/simulations/tragedy_of_the_commons/actions.py b/simulations/tragedy_of_the_commons/actions.py new file mode 100644 index 0000000..3345672 --- /dev/null +++ b/simulations/tragedy_of_the_commons/actions.py @@ -0,0 +1,162 @@ +""" +Defines the agent actions for the Tragedy of the Commons simulation. + +Each class implements the ActionInterface, defining a possible behavior. The +logic for how these actions affect the world is handled by Systems, which +listen for events published when an action's 'execute' method is called. +""" + +from typing import Any, Dict, List, cast + +from agent_core.agents.actions.action_interface import ActionInterface +from agent_core.agents.actions.action_registry import action_registry +from agent_core.agents.actions.action_outcome import ActionOutcome +from agent_core.core.ecs.abstractions import SimulationState as AbstractSimulationState + +from .components import PositionComponent +from .environment import CommonsEnvironment + + +@action_registry.register +class MoveAction(ActionInterface): + """Allows an agent to move to an adjacent, unoccupied cell.""" + + @property + def action_id(self) -> str: + return "move" + + @property + def name(self) -> str: + return "Move" + + def get_base_cost(self, simulation_state: AbstractSimulationState) -> float: + """The energy cost of moving.""" + return 1.0 + + def generate_possible_params( + self, entity_id: str, sim_state: AbstractSimulationState, tick: int + ) -> List[Dict[str, Any]]: + """Generates parameters for all valid moves (N, S, E, W).""" + if not hasattr(sim_state, "environment"): + return [] + + pos_comp = sim_state.get_component(entity_id, PositionComponent) + env = sim_state.environment + if not pos_comp or not isinstance(env, CommonsEnvironment): + return [] + + pos_comp = cast(PositionComponent, pos_comp) + + valid_moves = [] + for dx, dy in [(0, -1), (0, 1), (1, 0), (-1, 0)]: + new_pos = (pos_comp.position[0] + dx, pos_comp.position[1] + dy) + if env.is_valid_position(new_pos) and not env.is_occupied_by_agent(new_pos): + valid_moves.append({"target_pos": new_pos}) + return valid_moves + + def execute( + self, + entity_id: str, + sim_state: AbstractSimulationState, + params: Dict[str, Any], + tick: int, + ) -> ActionOutcome: + """Signals the intent to move; logic is handled by MovementSystem.""" + return ActionOutcome(success=True, message="Move initiated.", base_reward=0.0) + + def get_feature_vector( + self, entity_id: str, sim_state: AbstractSimulationState, params: Dict[str, Any] + ) -> List[float]: + """One-hot encoding for the action space.""" + return [1.0, 0.0, 0.0] # [is_move, is_graze, is_wait] + + +@action_registry.register +class GrazeAction(ActionInterface): + """Allows an agent to consume grass from its current cell.""" + + @property + def action_id(self) -> str: + return "graze" + + @property + def name(self) -> str: + return "Graze" + + def get_base_cost(self, simulation_state: AbstractSimulationState) -> float: + return 0.5 # Grazing has a small energy cost + + def generate_possible_params( + self, entity_id: str, sim_state: AbstractSimulationState, tick: int + ) -> List[Dict[str, Any]]: + """Action is possible if there is grass at the agent's location.""" + if not hasattr(sim_state, "environment"): + return [] + + pos_comp = sim_state.get_component(entity_id, PositionComponent) + env = sim_state.environment + if not pos_comp or not isinstance(env, CommonsEnvironment): + return [] + + pos_comp = cast(PositionComponent, pos_comp) + + if env.get_resource_at(pos_comp.position) > 0: + return [{}] # No parameters needed + return [] + + def execute( + self, + entity_id: str, + sim_state: AbstractSimulationState, + params: Dict[str, Any], + tick: int, + ) -> ActionOutcome: + """Signals intent to graze; logic is handled by GrazingSystem.""" + return ActionOutcome( + success=True, message="Grazing initiated.", base_reward=0.0 + ) + + def get_feature_vector( + self, entity_id: str, sim_state: AbstractSimulationState, params: Dict[str, Any] + ) -> List[float]: + """One-hot encoding for the action space.""" + return [0.0, 1.0, 0.0] # [is_move, is_graze, is_wait] + + +@action_registry.register +class WaitAction(ActionInterface): + """Allows an agent to do nothing for a turn.""" + + @property + def action_id(self) -> str: + return "wait" + + @property + def name(self) -> str: + return "Wait" + + def get_base_cost(self, simulation_state: AbstractSimulationState) -> float: + """Waiting has no additional energy cost beyond metabolism.""" + return 0.0 + + def generate_possible_params( + self, entity_id: str, sim_state: AbstractSimulationState, tick: int + ) -> List[Dict[str, Any]]: + """This action is always possible.""" + return [{}] + + def execute( + self, + entity_id: str, + sim_state: AbstractSimulationState, + params: Dict[str, Any], + tick: int, + ) -> ActionOutcome: + """Logic is handled by MetabolismSystem (passive energy decay).""" + return ActionOutcome(success=True, message="Agent waits.", base_reward=0.0) + + def get_feature_vector( + self, entity_id: str, sim_state: AbstractSimulationState, params: Dict[str, Any] + ) -> List[float]: + """One-hot encoding for the action space.""" + return [0.0, 0.0, 1.0] # [is_move, is_graze, is_wait] diff --git a/simulations/tragedy_of_the_commons/components.py b/simulations/tragedy_of_the_commons/components.py new file mode 100644 index 0000000..77a0661 --- /dev/null +++ b/simulations/tragedy_of_the_commons/components.py @@ -0,0 +1,126 @@ +""" +Defines the data components for the Tragedy of the Commons simulation. + +These classes represent the "nouns" of our simulationβ€”the data that defines +the state of agents (Herders) and the environment (Grass Patches). They are +intentionally simple and contain no logic. +""" + +from typing import Any, Dict, List, Tuple + +from agent_core.core.ecs.component import Component + + +class PositionComponent(Component): + """Stores an entity's x, y coordinates in the grid world.""" + + def __init__(self, x: int = 0, y: int = 0) -> None: + self.x = x + self.y = y + + @property + def position(self) -> Tuple[int, int]: + """Returns the current position as a tuple.""" + return (self.x, self.y) + + def to_dict(self) -> Dict[str, Any]: + """Serializes the component's data to a dictionary.""" + return {"x": int(self.x), "y": int(self.y)} + + def validate(self, entity_id: str) -> Tuple[bool, List[str]]: + """Validates the component's internal state.""" + errors: List[str] = [] + if not isinstance(self.x, int) or not isinstance(self.y, int): + errors.append("Position coordinates must be integers") + return len(errors) == 0, errors + + +class EnergyComponent(Component): + """ + Stores the energy level of a Herder agent, which is essential for survival. + Energy is consumed by metabolism and gained by grazing. + """ + + def __init__(self, current_energy: float, initial_energy: float) -> None: + self.current_energy = current_energy + self.initial_energy = initial_energy + + def to_dict(self) -> Dict[str, Any]: + """Serializes the component's data to a dictionary.""" + return { + "current_energy": self.current_energy, + "initial_energy": self.initial_energy, + } + + def validate(self, entity_id: str) -> Tuple[bool, List[str]]: + """Validates that energy is not negative.""" + if self.current_energy < 0: + return False, ["Energy cannot be negative."] + return True, [] + + +class ResourceComponent(Component): + """ + Represents a patch of grass, a shared and depletable resource. + + Attributes: + current_resource (float): The current amount of grass available. + max_resource (int): The maximum capacity of this patch. + regeneration_rate (float): The amount of grass that regrows each tick. + is_depleted (bool): A flag indicating if the resource is fully consumed. + """ + + def __init__( + self, + current_resource: float, + max_resource: int, + regeneration_rate: float, + ) -> None: + self.current_resource = float(current_resource) + self.max_resource = max_resource + self.regeneration_rate = regeneration_rate + self.is_depleted = self.current_resource <= 0 + + def consume(self, amount: float) -> float: + """ + Consumes a specified amount of the resource. + + Args: + amount: The amount of resource to consume. + + Returns: + The actual amount of resource that was consumed, which may be less + than requested if the resource is depleted. + """ + consumed_amount = min(self.current_resource, amount) + self.current_resource -= consumed_amount + if self.current_resource <= 0: + self.is_depleted = True + return consumed_amount + + def regenerate(self) -> None: + """Regenerates the resource by its regeneration rate.""" + if self.current_resource < self.max_resource: + self.current_resource = min( + self.max_resource, self.current_resource + self.regeneration_rate + ) + if self.current_resource > 0: + self.is_depleted = False + + def to_dict(self) -> Dict[str, Any]: + """Serializes the component's data.""" + return { + "current_resource": self.current_resource, + "max_resource": self.max_resource, + "regeneration_rate": self.regeneration_rate, + "is_depleted": self.is_depleted, + } + + def validate(self, entity_id: str) -> Tuple[bool, List[str]]: + """Validates the resource's state.""" + errors = [] + if self.current_resource < 0: + errors.append("Resource level cannot be negative.") + if self.max_resource <= 0: + errors.append("Max resource must be positive.") + return len(errors) == 0, errors diff --git a/simulations/tragedy_of_the_commons/config/config.yml b/simulations/tragedy_of_the_commons/config/config.yml new file mode 100644 index 0000000..0be622c --- /dev/null +++ b/simulations/tragedy_of_the_commons/config/config.yml @@ -0,0 +1,66 @@ +# simulations/tragedy_of_the_commons/config/config.yml +simulation_package: "simulations.tragedy_of_the_commons" + +simulation: + steps: 500 + log_directory: "data/logs/tragedy_of_the_commons" + random_seed: 42 + +environment: + class: "simulations.tragedy_of_the_commons.environment.CommonsEnvironment" + params: + width: 25 + height: 25 + initial_resource_level: 10.0 + max_resource_per_patch: 20 + resource_regeneration_rate: 0.1 + +agent: + initial_energy: 100.0 + metabolic_cost_per_tick: 0.5 + graze_amount: 5.0 + # Dummy values required by base schemas + foundational: + num_agents: 50 + cognitive: {} + costs: {} + dynamics: {} + emotional_dynamics: {} + identity_dynamics: {} + +learning: + q_learning: + alpha: 0.1 + gamma: 0.95 + initial_epsilon: 1.0 + epsilon_decay_rate: 0.9995 + min_epsilon: 0.05 + memory: {} + +scenario_path: "simulations/tragedy_of_the_commons/scenarios/default.json" + +scenario_loader: + class: "simulations.tragedy_of_the_commons.loader.CommonsScenarioLoader" + +action_generator: + class: "simulations.tragedy_of_the_commons.providers.CommonsActionGenerator" + +decision_selector: + class: "simulations.tragedy_of_the_commons.providers.HeuristicDecisionSelector" + +component_factory: + class: "simulations.tragedy_of_the_commons.providers.CommonsComponentFactory" + +actions: + - "simulations.tragedy_of_the_commons.actions" + +logging: + components_to_log: + - "simulations.tragedy_of_the_commons.components.PositionComponent" + - "simulations.tragedy_of_the_commons.components.EnergyComponent" + +rendering: + enabled: true + output_directory: "data/gif_renders/tragedy_of_the_commons" + frames_per_second: 15 + pixel_scale: 15 diff --git a/simulations/tragedy_of_the_commons/environment.py b/simulations/tragedy_of_the_commons/environment.py new file mode 100644 index 0000000..8bc0c02 --- /dev/null +++ b/simulations/tragedy_of_the_commons/environment.py @@ -0,0 +1,115 @@ +""" +Defines the environment for the Tragedy of the Commons simulation. + +This class manages the 2D grid, the grass resources, and provides an +interface for systems to query and interact with the world state. +""" + +from typing import Any, Dict, List, Optional, Set, Tuple + +import numpy as np +from agent_core.environment.interface import EnvironmentInterface + +from .components import ResourceComponent + + +class CommonsEnvironment(EnvironmentInterface): + """ + Manages the grid, grass resources, and agent positions for the simulation. + """ + + def __init__(self, width: int, height: int, rng: np.random.Generator): + self.width = width + self.height = height + self.rng = rng + self.agent_positions: Dict[str, Tuple[int, int]] = {} + # A grid to hold the entity IDs of the grass patches + self.resource_grid: Dict[Tuple[int, int], str] = {} + + def get_resource_at(self, position: Tuple[int, int]) -> float: + """Returns the amount of grass at a given position.""" + resource_id = self.resource_grid.get(position) + if resource_id and "simulation_state" in self.__dict__: + res_comp = self.simulation_state.get_component( + resource_id, ResourceComponent + ) + if res_comp: + return res_comp.current_resource + return 0.0 + + def consume_resource(self, position: Tuple[int, int], amount: float) -> float: + """Consumes grass from a patch and returns the amount consumed.""" + resource_id = self.resource_grid.get(position) + if resource_id and "simulation_state" in self.__dict__: + res_comp = self.simulation_state.get_component( + resource_id, ResourceComponent + ) + if res_comp: + return res_comp.consume(amount) + return 0.0 + + def get_all_empty_cells(self) -> List[Tuple[int, int]]: + """Returns a list of all cells not occupied by an agent.""" + all_cells = set((x, y) for x in range(self.width) for y in range(self.height)) + occupied_cells = set(self.agent_positions.values()) + return list(all_cells - occupied_cells) + + def is_occupied_by_agent(self, position: Tuple[int, int]) -> bool: + """Checks if a cell is occupied by an agent.""" + return position in self.agent_positions.values() + + # --- Interface Methods --- + + def update_entity_position( + self, entity_id: str, old_pos: Optional[Any], new_pos: Any + ) -> None: + self.agent_positions[entity_id] = new_pos + + def remove_entity(self, entity_id: str) -> None: + if entity_id in self.agent_positions: + del self.agent_positions[entity_id] + + def is_valid_position(self, position: Any) -> bool: + return 0 <= position[0] < self.width and 0 <= position[1] < self.height + + def get_entities_at_position(self, position: Any) -> Set[str]: + for agent_id, pos in self.agent_positions.items(): + if pos == position: + return {agent_id} + return set() + + def get_neighbors(self, position: Any) -> List[Any]: + x, y = position + neighbors = [] + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + if dx == 0 and dy == 0: + continue + nx, ny = x + dx, y + dy + if self.is_valid_position((nx, ny)): + neighbors.append((nx, ny)) + return neighbors + + def distance(self, pos1: Any, pos2: Any) -> float: + return float(abs(pos1[0] - pos2[0]) + abs(pos1[1] - pos2[1])) + + def to_dict(self) -> Dict[str, Any]: + return { + "width": self.width, + "height": self.height, + "agent_positions": self.agent_positions, + } + + def restore_from_dict(self, data: Dict[str, Any]) -> None: + self.width = data["width"] + self.height = data["height"] + self.agent_positions = {k: tuple(v) for k, v in data["agent_positions"].items()} + + def get_valid_positions(self) -> List[Any]: + return [(x, y) for x in range(self.width) for y in range(self.height)] + + def can_move(self, from_pos: Any, to_pos: Any) -> bool: + return not self.is_occupied_by_agent(to_pos) + + def get_entities_in_radius(self, center: Any, radius: int) -> List[Tuple[str, Any]]: + return [] diff --git a/simulations/tragedy_of_the_commons/experiments/toc_ab_test.yml b/simulations/tragedy_of_the_commons/experiments/toc_ab_test.yml new file mode 100644 index 0000000..7fe940d --- /dev/null +++ b/simulations/tragedy_of_the_commons/experiments/toc_ab_test.yml @@ -0,0 +1,24 @@ +experiment_name: "Commons - Heuristic vs QLearning" + +base_config_path: "simulations/tragedy_of_the_commons/config/config.yml" + +simulation_package: "simulations.tragedy_of_the_commons" + +# Use the standardized scenario to ensure a fair test +scenarios: + - "simulations/tragedy_of_the_commons/scenarios/default.json" + +# Run each variation multiple times for statistical significance +runs_per_scenario: 15 + +# Define all experimental groups, including both LLM agent types +variations: + - name: "GroupA-Baseline-Heuristic" + overrides: + decision_selector: + class: "simulations.tragedy_of_the_commons.providers.HeuristicDecisionSelector" + + - name: "GroupB-Experimental-QLearning" + overrides: + decision_selector: + class: "simulations.tragedy_of_the_commons.providers.QLearningDecisionSelector" diff --git a/simulations/tragedy_of_the_commons/loader.py b/simulations/tragedy_of_the_commons/loader.py new file mode 100644 index 0000000..a154020 --- /dev/null +++ b/simulations/tragedy_of_the_commons/loader.py @@ -0,0 +1,81 @@ +""" +Defines the ScenarioLoader for the Tragedy of the Commons simulation. + +This class reads a scenario JSON file and populates the simulation state +with the initial set of agents (Herders) and resource patches (Grass). +""" + +import json +from typing import Any + +import numpy as np +from agent_core.core.ecs.component import TimeBudgetComponent +from agent_core.simulation.scenario_loader_interface import ScenarioLoaderInterface + +from .components import EnergyComponent, PositionComponent, ResourceComponent +from .environment import CommonsEnvironment + + +class CommonsScenarioLoader(ScenarioLoaderInterface): + """ + Loads the Tragedy of the Commons scenario, creating agents and grass patches. + """ + + def __init__( + self, simulation_state: Any, scenario_path: str, rng: np.random.Generator + ): + self.simulation_state = simulation_state + self.scenario_path = scenario_path + self.rng = rng + + def load(self) -> None: + """ + Loads the scenario, initializes the environment, and creates entities. + """ + with open(self.scenario_path, "r") as f: + scenario_data = json.load(f) + + env = self.simulation_state.environment + if not isinstance(env, CommonsEnvironment): + raise TypeError("Environment must be a CommonsEnvironment instance.") + + # Create resource patches (grass) for every cell + for y in range(env.height): + for x in range(env.width): + resource_id = f"grass_{x}_{y}" + self.simulation_state.add_entity(resource_id) + self.simulation_state.add_component( + resource_id, + ResourceComponent( + current_resource=self.simulation_state.config.environment.params.initial_resource_level, + max_resource=self.simulation_state.config.environment.params.max_resource_per_patch, + regeneration_rate=self.simulation_state.config.environment.params.resource_regeneration_rate, + ), + ) + env.resource_grid[(x, y)] = resource_id + + # Create agents (Herders) + num_agents = scenario_data["num_agents"] + initial_energy = self.simulation_state.config.agent.initial_energy + empty_cells = env.get_all_empty_cells() + start_positions = self.rng.choice( + empty_cells, size=min(num_agents, len(empty_cells)), replace=False + ) + + for i, pos_tuple in enumerate(start_positions): + pos = tuple(pos_tuple) + agent_id = f"herder_{i:03d}" + self.simulation_state.add_entity(agent_id) + self.simulation_state.add_component( + agent_id, PositionComponent(x=pos[0], y=pos[1]) + ) + self.simulation_state.add_component( + agent_id, + EnergyComponent( + current_energy=initial_energy, initial_energy=initial_energy + ), + ) + self.simulation_state.add_component( + agent_id, TimeBudgetComponent(initial_time_budget=99999) + ) + env.update_entity_position(agent_id, None, pos) diff --git a/simulations/tragedy_of_the_commons/metrics/metrics_calculator.py b/simulations/tragedy_of_the_commons/metrics/metrics_calculator.py new file mode 100644 index 0000000..4524043 --- /dev/null +++ b/simulations/tragedy_of_the_commons/metrics/metrics_calculator.py @@ -0,0 +1,53 @@ +""" +Defines the metrics calculator for the Tragedy of the Commons simulation. +""" + +from typing import Any, Dict, cast + +from agent_core.core.ecs.component import TimeBudgetComponent +from agent_engine.logging.metrics_calculator_interface import MetricsCalculatorInterface + +from ..components import EnergyComponent, ResourceComponent + + +class CommonsMetricsCalculator(MetricsCalculatorInterface): + """ + Calculates simulation-wide metrics for the Tragedy of the Commons model. + """ + + def calculate_metrics(self, simulation_state: Any) -> Dict[str, Any]: + """ + Calculates total resources in the commons and average agent energy. + """ + # Calculate total resources + resource_patches = simulation_state.get_entities_with_components( + [ResourceComponent] + ) + total_resources = sum( + cast(ResourceComponent, comps.get(ResourceComponent)).current_resource + for comps in resource_patches.values() + ) + + # Calculate average agent energy + agents = simulation_state.get_entities_with_components( + [EnergyComponent, TimeBudgetComponent] + ) + total_energy = 0.0 # FIX: Initialize as a float + active_agents_count = 0 + for comps in agents.values(): + time_comp = cast(TimeBudgetComponent, comps.get(TimeBudgetComponent)) + if time_comp.is_active: + active_agents_count += 1 + energy_comp = cast(EnergyComponent, comps.get(EnergyComponent)) + if energy_comp: + total_energy += energy_comp.current_energy + + average_energy = ( + total_energy / active_agents_count if active_agents_count > 0 else 0.0 + ) + + return { + "active_agents": active_agents_count, + "total_resources_in_commons": total_resources, + "average_agent_energy": average_energy, + } diff --git a/simulations/tragedy_of_the_commons/providers.py b/simulations/tragedy_of_the_commons/providers.py new file mode 100644 index 0000000..3d2c9e7 --- /dev/null +++ b/simulations/tragedy_of_the_commons/providers.py @@ -0,0 +1,273 @@ +""" +Defines the Provider classes for the Tragedy of the Commons simulation. + +Providers bridge the world-agnostic agent-engine and the specific rules of +this simulation. They implement interfaces from agent-core to supply +world-specific data and logic to the core cognitive systems. +""" + +import random +from typing import Any, Dict, List, Optional, Tuple, Type, cast + +import numpy as np +import torch +from agent_core.agents.action_cost_provider_interface import ActionCostProviderInterface +from agent_core.agents.action_generator_interface import ActionGeneratorInterface +from agent_core.agents.actions.action_interface import ActionInterface +from agent_core.agents.actions.action_registry import action_registry +from agent_core.agents.decision_selector_interface import DecisionSelectorInterface +from agent_core.core.ecs.abstractions import SimulationState as AbstractSimulationState +from agent_core.core.ecs.component import ActionPlanComponent, Component +from agent_core.core.ecs.component_factory_interface import ComponentFactoryInterface +from agent_core.policy.reward_calculator_interface import RewardCalculatorInterface +from agent_core.policy.state_encoder_interface import StateEncoderInterface +from agent_engine.simulation.simulation_state import SimulationState +from agent_engine.systems.components import QLearningComponent + +from .components import EnergyComponent, PositionComponent, ResourceComponent +from .environment import CommonsEnvironment + +__all__ = [ + "CommonsActionGenerator", + "HeuristicDecisionSelector", + "QLearningDecisionSelector", + "CommonsStateEncoder", + "CommonsComponentFactory", + "CommonsRewardCalculator", + "CommonsActionCostProvider", +] + + +class CommonsActionGenerator(ActionGeneratorInterface): + """Generates possible actions for Herder agents.""" + + def generate( + self, sim_state: AbstractSimulationState, entity_id: str, tick: int + ) -> List[ActionPlanComponent]: + """Generates Graze, Move, and Wait actions.""" + all_plans: List[ActionPlanComponent] = [] + for action_class in action_registry.get_all_actions(): + action_instance = action_class() + params_list = action_instance.generate_possible_params( + entity_id, sim_state, tick + ) + all_plans.extend( + [ + ActionPlanComponent(action_type=action_instance, params=p) + for p in params_list + ] + ) + return all_plans + + +class HeuristicDecisionSelector(DecisionSelectorInterface): + """A simple, rule-based decision policy for baseline agents.""" + + def __init__(self, simulation_state: Any = None, config: Any = None): + """Initializes the selector.""" + pass + + def select( + self, + sim_state: AbstractSimulationState, + entity_id: str, + possible_actions: List[ActionPlanComponent], + ) -> Optional[ActionPlanComponent]: + """Selects an action based on a simple survival heuristic.""" + if not possible_actions: + return None + + # 1. If agent can graze, always graze. + graze_actions = [ + a + for a in possible_actions + if a.action_type and a.action_type.action_id == "graze" + ] + if graze_actions: + return graze_actions[0] + + # 2. If not, move towards the richest adjacent grass patch. + move_actions = [ + a + for a in possible_actions + if a.action_type and a.action_type.action_id == "move" + ] + wait_actions = [ + a + for a in possible_actions + if a.action_type and a.action_type.action_id == "wait" + ] + + if move_actions and isinstance(sim_state.environment, CommonsEnvironment): + env = cast(CommonsEnvironment, sim_state.environment) + + # Find the move action that goes to the patch with the most resources. + best_move = max( + move_actions, + key=lambda m: env.get_resource_at(m.params["target_pos"]), + default=None, + ) + + if best_move: + # Get the resource amount at the best location. + max_resource = env.get_resource_at(best_move.params["target_pos"]) + # Only move if it's better than nothing. + if max_resource > 0: + return best_move + + # 3. If no beneficial move is found, wait. + return wait_actions[0] if wait_actions else None + + +class QLearningDecisionSelector(DecisionSelectorInterface): + """A decision selector that uses the agent's Q-learning network.""" + + def __init__(self, simulation_state: Any, config: Any): + self.simulation_state = simulation_state + self.config = config + self.state_encoder = CommonsStateEncoder() + + def select( + self, + sim_state: AbstractSimulationState, + entity_id: str, + possible_actions: List[ActionPlanComponent], + ) -> Optional[ActionPlanComponent]: + """Uses epsilon-greedy strategy to select an action.""" + if not possible_actions or not isinstance(sim_state, SimulationState): + return None + + q_comp = sim_state.get_component(entity_id, QLearningComponent) + if not q_comp: + return random.choice(possible_actions) + q_comp = cast(QLearningComponent, q_comp) + + if random.random() < q_comp.current_epsilon: + return random.choice(possible_actions) + + with torch.no_grad(): + best_action = None + max_q_value = -float("inf") + state_features = self.state_encoder.encode_state( + sim_state, entity_id, self.config + ) + state_tensor = torch.tensor(state_features, dtype=torch.float32).unsqueeze( + 0 + ) + internal_tensor = torch.tensor([0.0], dtype=torch.float32).unsqueeze(0) + + for action_plan in possible_actions: + if not isinstance(action_plan.action_type, ActionInterface): + continue + action_features = action_plan.action_type.get_feature_vector( + entity_id, sim_state, action_plan.params + ) + action_tensor = torch.tensor( + action_features, dtype=torch.float32 + ).unsqueeze(0) + q_value = q_comp.utility_network( + state_tensor, internal_tensor, action_tensor + ).item() + if q_value > max_q_value: + max_q_value = q_value + best_action = action_plan + return best_action + + +class CommonsStateEncoder(StateEncoderInterface): + """Encodes the simulation state into a feature vector for the Q-Learning model.""" + + def encode_state( + self, + sim_state: AbstractSimulationState, + entity_id: str, + config: Any, + target_entity_id: Optional[str] = None, + ) -> np.ndarray: + """Encodes local resource levels and agent's energy.""" + if not isinstance(sim_state.environment, CommonsEnvironment): + return np.zeros(6, dtype=np.float32) + + env = cast(CommonsEnvironment, sim_state.environment) + pos_comp = sim_state.get_component(entity_id, PositionComponent) + energy_comp = sim_state.get_component(entity_id, EnergyComponent) + + if not pos_comp or not energy_comp: + return np.zeros(6, dtype=np.float32) + + pos_comp = cast(PositionComponent, pos_comp) + energy_comp = cast(EnergyComponent, energy_comp) + + # Normalized energy level + norm_energy = energy_comp.current_energy / energy_comp.initial_energy + + # Resource levels for cardinal directions + current location + center = env.get_resource_at(pos_comp.position) + north = env.get_resource_at((pos_comp.x, pos_comp.y - 1)) + south = env.get_resource_at((pos_comp.x, pos_comp.y + 1)) + east = env.get_resource_at((pos_comp.x + 1, pos_comp.y)) + west = env.get_resource_at((pos_comp.x - 1, pos_comp.y)) + + max_res = sim_state.config.environment.params.max_resource_per_patch + + feature_vector = [ + norm_energy, + center / max_res if max_res > 0 else 0, + north / max_res if max_res > 0 else 0, + south / max_res if max_res > 0 else 0, + east / max_res if max_res > 0 else 0, + west / max_res if max_res > 0 else 0, + ] + + return np.array(feature_vector, dtype=np.float32) + + def encode_internal_state( + self, components: Dict[Type[Component], Component], config: Any + ) -> np.ndarray: + return np.array([0.0], dtype=np.float32) + + +class CommonsComponentFactory(ComponentFactoryInterface): + """Creates component instances from saved data.""" + + def create_component(self, component_type: str, data: Dict[str, Any]) -> Component: + class_name = component_type.split(".")[-1] + component_map: Dict[str, Type[Component]] = { + "PositionComponent": PositionComponent, + "EnergyComponent": EnergyComponent, + "ResourceComponent": ResourceComponent, + } + if class_name in component_map: + return component_map[class_name](**data) + raise TypeError(f"Unknown component type for factory: {component_type}") + + +class CommonsRewardCalculator(RewardCalculatorInterface): + """A simple reward calculator that returns the base reward.""" + + def calculate_final_reward( + self, + base_reward: float, + action_type: Any, + action_intent: str, + outcome_details: Dict[str, Any], + entity_components: Dict[Type["Component"], "Component"], + ) -> Tuple[float, Dict[str, Any]]: + """Simply returns the base reward of the action without modification.""" + return base_reward, {"base_reward": base_reward} + + +class CommonsActionCostProvider(ActionCostProviderInterface): + """Applies action costs to an agent's EnergyComponent.""" + + def apply_action_cost( + self, + entity_id: str, + cost: float, + simulation_state: "AbstractSimulationState", + ) -> None: + """Deducts the cost from the agent's current energy.""" + energy_comp = simulation_state.get_component(entity_id, EnergyComponent) + if energy_comp: + energy_comp = cast(EnergyComponent, energy_comp) + energy_comp.current_energy -= cost diff --git a/simulations/tragedy_of_the_commons/renderer.py b/simulations/tragedy_of_the_commons/renderer.py new file mode 100644 index 0000000..524c5ce --- /dev/null +++ b/simulations/tragedy_of_the_commons/renderer.py @@ -0,0 +1,92 @@ +""" +Defines the renderer for the Tragedy of the Commons simulation. + +This class is responsible for creating a visual representation of the +simulation state at each tick and saving it as an image frame. +""" + +import numpy as np +import imageio +from pathlib import Path +from typing import Any, cast + +from .components import PositionComponent, ResourceComponent +from .environment import CommonsEnvironment + + +class CommonsRenderer: + """Renders the state of the Commons simulation grid to an image.""" + + def __init__(self, width: int, height: int, output_dir: str, pixel_scale: int = 1): + self.width = width + self.height = height + self.output_path = Path(output_dir) + self.output_path.mkdir(parents=True, exist_ok=True) + self.pixel_scale = pixel_scale + + # Define colors + self.agent_color = np.array([236, 240, 241], dtype=np.uint8) # White + self.grass_min_color = np.array([46, 204, 113], dtype=np.uint8) # Dark Green + self.grass_max_color = np.array([22, 160, 133], dtype=np.uint8) # Darker Green + self.depleted_color = np.array([210, 179, 134], dtype=np.uint8) # Brown/Dirt + + def render_frame(self, simulation_state: Any, tick: int) -> None: + """Creates and saves a single frame of the simulation.""" + scaled_height = self.height * self.pixel_scale + scaled_width = self.width * self.pixel_scale + + grid = np.full( + (scaled_height, scaled_width, 3), self.depleted_color, dtype=np.uint8 + ) + + # Draw grass first + self._draw_grass(grid, simulation_state) + + # Draw agents on top + self._draw_agents(grid, simulation_state) + + frame_path = self.output_path / f"frame_{tick:04d}.png" + imageio.imwrite(frame_path, grid) + + def _draw_pixel_block(self, grid, x, y, color): + """Draws a scaled block of pixels on the grid.""" + y_start = y * self.pixel_scale + y_end = y_start + self.pixel_scale + x_start = x * self.pixel_scale + x_end = x_start + self.pixel_scale + grid[y_start:y_end, x_start:x_end] = color + + def _draw_grass(self, grid: np.ndarray, sim_state: Any): + """Draws the grass distribution on the grid.""" + resource_patches = sim_state.get_entities_with_components([ResourceComponent]) + env = sim_state.environment + if not isinstance(env, CommonsEnvironment): + return + + for entity_id, components in resource_patches.items(): + res_comp = cast(ResourceComponent, components.get(ResourceComponent)) + + # Find the position from the resource_grid in the environment + pos = None + for p, eid in env.resource_grid.items(): + if eid == entity_id: + pos = p + break + + if pos: + if res_comp.is_depleted: + color = self.depleted_color + else: + ratio = res_comp.current_resource / res_comp.max_resource + color = ( + self.grass_min_color * (1 - ratio) + + self.grass_max_color * ratio + ) + self._draw_pixel_block(grid, pos[0], pos[1], color.astype(np.uint8)) + + def _draw_agents(self, grid: np.ndarray, sim_state: Any): + """Draws agents on the grid.""" + entities = sim_state.get_entities_with_components([PositionComponent]) + for _, components in entities.items(): + pos_comp = cast(PositionComponent, components.get(PositionComponent)) + self._draw_pixel_block(grid, pos_comp.x, pos_comp.y, self.agent_color) diff --git a/simulations/tragedy_of_the_commons/run.py b/simulations/tragedy_of_the_commons/run.py new file mode 100644 index 0000000..dea511a --- /dev/null +++ b/simulations/tragedy_of_the_commons/run.py @@ -0,0 +1,201 @@ +""" +Main entry point for the Tragedy of the Commons simulation. + +This script orchestrates the setup and execution of the simulation. It +initializes the SimulationManager, registers all necessary systems (both +world-specific and from the agent-engine), injects provider implementations, +loads the scenario, and starts the main simulation loop. +""" + +import asyncio +import importlib +import os +import uuid +from typing import Any, Dict, Type + +import mlflow +import numpy as np +import torch +from agent_engine.simulation.engine import SimulationManager +from agent_engine.systems.action_system import ActionSystem +from agent_engine.systems.components import QLearningComponent +from agent_engine.systems.logging_system import LoggingSystem +from agent_engine.systems.metrics_system import MetricsSystem +from agent_engine.systems.q_learning_system import QLearningSystem +from agent_sim.infrastructure.database.async_database_manager import ( + AsyncDatabaseManager, +) +from agent_sim.infrastructure.logging.database_emitter import DatabaseEmitter +from agent_sim.infrastructure.logging.mlflow_exporter import MLflowExporter +from omegaconf import OmegaConf + +from .environment import CommonsEnvironment +from .loader import CommonsScenarioLoader +from .metrics.metrics_calculator import CommonsMetricsCalculator +from .providers import ( + CommonsActionCostProvider, + CommonsActionGenerator, + CommonsComponentFactory, + CommonsRewardCalculator, + CommonsStateEncoder, + QLearningDecisionSelector, +) +from .systems import ( + GrazingSystem, + MetabolismSystem, + MovementSystem, + RenderingSystem, + ResourceRegenerationSystem, + VitalsSystem, +) + + +def import_class(class_path: str) -> Type: + """Dynamically imports a class from its string path.""" + try: + module_path, class_name = class_path.rsplit(".", 1) + module = importlib.import_module(module_path) + return getattr(module, class_name) + except (ImportError, AttributeError, ValueError) as e: + print(f"[ERROR] Failed to import class at path '{class_path}': {e}") + raise + + +async def setup_and_run( + run_id: str, + task_id: str, + experiment_id: str, + config_overrides: Dict[str, Any], +): + """Initializes and runs the full simulation logic.""" + config = OmegaConf.create(config_overrides) + + db_manager = AsyncDatabaseManager() + await db_manager.check_connection() + + run_uuid = uuid.UUID(run_id) + db_emitter = DatabaseEmitter(db_manager=db_manager, simulation_id=run_uuid) + mlflow_exporter = MLflowExporter() + + seed = config.simulation.get("random_seed") + master_rng = ( + np.random.default_rng(seed) if seed is not None else np.random.default_rng() + ) + + env_params = { + "width": config.environment.params.width, + "height": config.environment.params.height, + "rng": master_rng, + } + + env_class = import_class(config.environment["class"]) + env: CommonsEnvironment = env_class(**env_params) + + decision_selector_class = import_class(config.decision_selector["class"]) + + loader = CommonsScenarioLoader( + simulation_state=None, scenario_path=config.scenario_path, rng=master_rng + ) + action_generator = CommonsActionGenerator() + decision_selector = decision_selector_class(simulation_state=None, config=config) + + manager = SimulationManager( + config=config, + environment=env, + scenario_loader=loader, + action_generator=action_generator, + decision_selector=decision_selector, + component_factory=CommonsComponentFactory(), + db_logger=db_manager, + run_id=run_id, + task_id=task_id, + experiment_id=experiment_id, + ) + + # Connect providers to the manager's state + loader.simulation_state = manager.simulation_state + decision_selector.simulation_state = manager.simulation_state + env.simulation_state = manager.simulation_state + + # Instantiate providers + state_encoder = CommonsStateEncoder() + reward_calculator = CommonsRewardCalculator() + action_cost_provider = CommonsActionCostProvider() + + # Register world-specific systems + manager.register_system(MetabolismSystem) + manager.register_system(VitalsSystem) + manager.register_system(MovementSystem) + manager.register_system(GrazingSystem) + manager.register_system(ResourceRegenerationSystem) + + # Register engine systems + manager.register_system( + ActionSystem, + reward_calculator=reward_calculator, + action_cost_provider=action_cost_provider, + ) + manager.register_system( + QLearningSystem, state_encoder=state_encoder, causal_graph_system=None + ) + + # Register utility systems + metrics_calculator = CommonsMetricsCalculator() + manager.register_system( + MetricsSystem, + calculators=[metrics_calculator], + exporters=[mlflow_exporter, db_emitter], + ) + manager.register_system(LoggingSystem, exporters=[db_emitter]) + + if config.rendering.get("enabled", False): + manager.register_system(RenderingSystem) + + # Load actions and scenario + for action_path in config.actions: + importlib.import_module(action_path) + loader.load() + + # Add QLearningComponent if using the Q-learning selector + if decision_selector_class is QLearningDecisionSelector: + for agent_id in manager.simulation_state.entities: + if agent_id.startswith("herder_"): + manager.simulation_state.add_component( + agent_id, + QLearningComponent( + state_feature_dim=6, # energy + 5 resource patches + internal_state_dim=1, + action_feature_dim=3, # move, graze, wait + q_learning_alpha=config.learning.q_learning.alpha, + device=torch.device("cpu"), + ), + ) + + print( + f"πŸš€ Starting simulation (Run ID: {run_id}) for {config.simulation.steps} steps..." + ) + await manager.run() + print(f"βœ… Simulation {run_id} completed.") + + +def start_simulation( + run_id: str, task_id: str, experiment_name: str, config_overrides: Dict[str, Any] +): + """Synchronous entry point for external callers like Celery tasks.""" + if not run_id: + mlflow.set_tracking_uri( + os.getenv("MLFLOW_TRACKING_URI", "http://localhost:5001") + ) + experiment = mlflow.get_experiment_by_name(experiment_name) + experiment_id = ( + experiment.experiment_id + if experiment + else mlflow.create_experiment(name=experiment_name) + ) + with mlflow.start_run(experiment_id=experiment_id) as run: + current_run_id = run.info.run_id + asyncio.run( + setup_and_run(current_run_id, task_id, experiment_id, config_overrides) + ) + else: + asyncio.run(setup_and_run(run_id, task_id, experiment_name, config_overrides)) diff --git a/simulations/tragedy_of_the_commons/scenarios/default.json b/simulations/tragedy_of_the_commons/scenarios/default.json new file mode 100644 index 0000000..93e4e2d --- /dev/null +++ b/simulations/tragedy_of_the_commons/scenarios/default.json @@ -0,0 +1,5 @@ +{ + "name": "Default Commons Scenario", + "description": "A standard setup with 50 Herder agents.", + "num_agents": 50 +} diff --git a/simulations/tragedy_of_the_commons/systems.py b/simulations/tragedy_of_the_commons/systems.py new file mode 100644 index 0000000..ed734ef --- /dev/null +++ b/simulations/tragedy_of_the_commons/systems.py @@ -0,0 +1,191 @@ +""" +Defines the logic systems for the Tragedy of the Commons simulation. + +Systems contain the "verb" logic of the simulation. They operate on entities +that have a specific set of components and communicate with each other +indirectly through an event bus. +""" + +from typing import Any, Dict, List, Type, cast + +from agent_core.agents.actions.action_outcome import ActionOutcome +from agent_core.core.ecs.component import Component, TimeBudgetComponent +from agent_engine.simulation.system import System + +from .components import EnergyComponent, PositionComponent, ResourceComponent +from .environment import CommonsEnvironment +from .renderer import CommonsRenderer + + +class MetabolismSystem(System): + """Handles agent metabolism (passive energy decay).""" + + REQUIRED_COMPONENTS: List[Type[Component]] = [EnergyComponent, TimeBudgetComponent] + + async def update(self, current_tick: int) -> None: + """Processes passive energy decay for all active agents.""" + agents = self.simulation_state.get_entities_with_components( + self.REQUIRED_COMPONENTS + ) + metabolic_cost = self.config.agent.metabolic_cost_per_tick + + for _, components in agents.items(): + energy_comp = cast(EnergyComponent, components.get(EnergyComponent)) + time_comp = cast(TimeBudgetComponent, components.get(TimeBudgetComponent)) + + if time_comp.is_active: + energy_comp.current_energy -= metabolic_cost + + +class VitalsSystem(System): + """Checks agent vitals and handles deactivation (death) when energy is depleted.""" + + REQUIRED_COMPONENTS: List[Type[Component]] = [EnergyComponent, TimeBudgetComponent] + + async def update(self, current_tick: int) -> None: + """Checks for agents with zero or less energy and deactivates them.""" + env = self.simulation_state.environment + if not isinstance(env, CommonsEnvironment): + return + + agents = self.simulation_state.get_entities_with_components( + self.REQUIRED_COMPONENTS + ) + + for agent_id, components in list(agents.items()): + energy_comp = cast(EnergyComponent, components.get(EnergyComponent)) + time_comp = cast(TimeBudgetComponent, components.get(TimeBudgetComponent)) + + if time_comp.is_active and energy_comp.current_energy <= 0: + time_comp.is_active = False + env.remove_entity(agent_id) + if self.event_bus: + self.event_bus.publish( + "agent_deactivated", + {"entity_id": agent_id, "current_tick": current_tick}, + ) + + +class MovementSystem(System): + """Handles the execution of agent movement actions.""" + + def __init__(self, sim_state: Any, config: Any, scaffold: Any): + super().__init__(sim_state, config, scaffold) + if self.event_bus: + self.event_bus.subscribe("execute_move_action", self.on_move) + + def on_move(self, event_data: Dict[str, Any]): + """Event handler for when a move action is chosen.""" + entity_id = event_data["entity_id"] + params = event_data["action_plan_component"].params + pos_comp = self.simulation_state.get_component(entity_id, PositionComponent) + env = self.simulation_state.environment + + if not pos_comp or not isinstance(env, CommonsEnvironment): + self._publish_outcome(event_data, False, -1.0, "Missing components.") + return + + pos_comp = cast(PositionComponent, pos_comp) + + target_pos = params["target_pos"] + old_pos = pos_comp.position + pos_comp.x, pos_comp.y = target_pos + env.update_entity_position(entity_id, old_pos, target_pos) + + self._publish_outcome(event_data, True, 0.0, "Move successful.") + + def _publish_outcome( + self, event_data: Dict[str, Any], success: bool, reward: float, message: str + ): + """Publishes the final outcome of the action.""" + event_data["action_outcome"] = ActionOutcome( + success, message, base_reward=reward + ) + event_data["original_action_plan"] = event_data.pop("action_plan_component") + if self.event_bus: + self.event_bus.publish("action_outcome_ready", event_data) + + async def update(self, current_tick: int): + """This system is purely event-driven.""" + pass + + +class GrazingSystem(System): + """Handles the execution of resource grazing actions.""" + + def __init__(self, sim_state: Any, config: Any, scaffold: Any): + super().__init__(sim_state, config, scaffold) + if self.event_bus: + self.event_bus.subscribe("execute_graze_action", self.on_graze) + + def on_graze(self, event_data: Dict[str, Any]): + """Event handler for when a graze action is chosen.""" + entity_id = event_data["entity_id"] + pos_comp = self.simulation_state.get_component(entity_id, PositionComponent) + energy_comp = self.simulation_state.get_component(entity_id, EnergyComponent) + env = self.simulation_state.environment + + if not pos_comp or not energy_comp or not isinstance(env, CommonsEnvironment): + self._publish_outcome(event_data, False, -1.0, "Missing components.") + return + + pos_comp = cast(PositionComponent, pos_comp) + energy_comp = cast(EnergyComponent, energy_comp) + + graze_amount = self.config.agent.graze_amount + consumed_amount = env.consume_resource(pos_comp.position, graze_amount) + energy_comp.current_energy += consumed_amount + + self._publish_outcome( + event_data, + True, + float(consumed_amount), + f"Grazed {consumed_amount:.2f} resources.", + ) + + def _publish_outcome( + self, event_data: Dict[str, Any], success: bool, reward: float, message: str + ): + """Publishes the final outcome of the action.""" + event_data["action_outcome"] = ActionOutcome( + success, message, base_reward=reward + ) + event_data["original_action_plan"] = event_data.pop("action_plan_component") + if self.event_bus: + self.event_bus.publish("action_outcome_ready", event_data) + + async def update(self, current_tick: int): + """This system is purely event-driven.""" + pass + + +class ResourceRegenerationSystem(System): + """Handles the regeneration of grass resources across the environment.""" + + REQUIRED_COMPONENTS: List[Type[Component]] = [ResourceComponent] + + async def update(self, current_tick: int) -> None: + """Processes resource regeneration for all grass patches.""" + resource_patches = self.simulation_state.get_entities_with_components( + self.REQUIRED_COMPONENTS + ) + for _, components in resource_patches.items(): + res_comp = cast(ResourceComponent, components.get(ResourceComponent)) + res_comp.regenerate() + + +class RenderingSystem(System): + """A system that renders the simulation state to an image at each tick.""" + + def __init__(self, simulation_state: Any, config: Any, cognitive_scaffold: Any): + super().__init__(simulation_state, config, cognitive_scaffold) + self.renderer = CommonsRenderer( + width=config.environment.params.width, + height=config.environment.params.height, + output_dir=f"{config.rendering.output_directory}/{simulation_state.simulation_id}", + pixel_scale=config.rendering.pixel_scale, + ) + + async def update(self, current_tick: int) -> None: + """On each tick, render a new frame.""" + self.renderer.render_frame(self.simulation_state, current_tick) diff --git a/tests/simulations/tragedy_of_the_commons/test_toc_components.py b/tests/simulations/tragedy_of_the_commons/test_toc_components.py new file mode 100644 index 0000000..f0d5b8e --- /dev/null +++ b/tests/simulations/tragedy_of_the_commons/test_toc_components.py @@ -0,0 +1,134 @@ +""" +Unit tests for the components in the Tragedy of the Commons simulation. + +This file tests the data integrity, validation, and serialization methods of +each component to ensure they behave as expected. +""" + +import pytest +from simulations.tragedy_of_the_commons.components import ( + EnergyComponent, + PositionComponent, + ResourceComponent, +) + + +class TestEnergyComponent: + """Tests for the EnergyComponent.""" + + def test_initialization(self): + """Test that the component initializes with correct values.""" + comp = EnergyComponent(current_energy=80.0, initial_energy=100.0) + assert comp.current_energy == 80.0 + assert comp.initial_energy == 100.0 + + def test_to_dict_serialization(self): + """Test that the to_dict method serializes data correctly.""" + comp = EnergyComponent(current_energy=75.5, initial_energy=100.0) + expected_dict = { + "current_energy": 75.5, + "initial_energy": 100.0, + } + assert comp.to_dict() == expected_dict + + def test_validation_succeeds_for_valid_state(self): + """Test that validation passes for non-negative energy.""" + comp = EnergyComponent(current_energy=0.0, initial_energy=100.0) + is_valid, errors = comp.validate("agent_1") + assert is_valid + assert not errors + + def test_validation_fails_for_negative_energy(self): + """Test that validation fails when current_energy is negative.""" + comp = EnergyComponent(current_energy=-10.0, initial_energy=100.0) + is_valid, errors = comp.validate("agent_1") + assert not is_valid + assert "Energy cannot be negative" in errors[0] + + +class TestResourceComponent: + """Tests for the ResourceComponent.""" + + @pytest.fixture + def resource_comp(self) -> ResourceComponent: + """Provides a standard ResourceComponent for tests.""" + return ResourceComponent( + current_resource=10.0, max_resource=20, regeneration_rate=0.5 + ) + + def test_initialization(self, resource_comp: ResourceComponent): + """Test that the component initializes correctly.""" + assert resource_comp.current_resource == 10.0 + assert resource_comp.max_resource == 20 + assert resource_comp.regeneration_rate == 0.5 + assert not resource_comp.is_depleted + + def test_consume_less_than_available(self, resource_comp: ResourceComponent): + """Test consuming an amount smaller than the current resource.""" + consumed = resource_comp.consume(5.0) + assert consumed == 5.0 + assert resource_comp.current_resource == 5.0 + assert not resource_comp.is_depleted + + def test_consume_more_than_available(self, resource_comp: ResourceComponent): + """Test consuming an amount larger than the current resource.""" + consumed = resource_comp.consume(15.0) + assert consumed == 10.0 + assert resource_comp.current_resource == 0.0 + assert resource_comp.is_depleted + + def test_regenerate_below_max(self, resource_comp: ResourceComponent): + """Test resource regeneration when below the maximum.""" + resource_comp.regenerate() + assert resource_comp.current_resource == 10.5 + + def test_regenerate_at_max(self, resource_comp: ResourceComponent): + """Test that regeneration does not exceed the maximum resource level.""" + resource_comp.current_resource = 20.0 + resource_comp.regenerate() + assert resource_comp.current_resource == 20.0 + + def test_regenerate_near_max(self, resource_comp: ResourceComponent): + """Test that regeneration caps at the maximum resource level.""" + resource_comp.current_resource = 19.8 + resource_comp.regenerate() + assert resource_comp.current_resource == 20.0 + + def test_validation_fails_for_negative_resource(self): + """Test that validation fails for negative resource values.""" + comp = ResourceComponent( + current_resource=-5.0, max_resource=20, regeneration_rate=0.5 + ) + is_valid, errors = comp.validate("grass_1") + assert not is_valid + assert "Resource level cannot be negative" in errors[0] + + +class TestPositionComponent: + """Tests for the PositionComponent.""" + + def test_initialization_and_position_property(self): + """Test component initialization and the .position property.""" + comp = PositionComponent(x=10, y=15) + assert comp.x == 10 + assert comp.y == 15 + assert comp.position == (10, 15) + + def test_to_dict_serialization(self): + """Test that to_dict serializes data correctly.""" + comp = PositionComponent(x=5, y=8) + assert comp.to_dict() == {"x": 5, "y": 8} + + def test_validation_succeeds_for_integers(self): + """Test that validation passes for integer coordinates.""" + comp = PositionComponent(x=0, y=0) + is_valid, errors = comp.validate("agent_1") + assert is_valid + assert not errors + + def test_validation_fails_for_non_integers(self): + """Test that validation fails for non-integer coordinates.""" + comp = PositionComponent(x=1.5, y=2) # type: ignore + is_valid, errors = comp.validate("agent_1") + assert not is_valid + assert "Position coordinates must be integers" in errors[0] diff --git a/tests/simulations/tragedy_of_the_commons/test_toc_environment.py b/tests/simulations/tragedy_of_the_commons/test_toc_environment.py new file mode 100644 index 0000000..892b421 --- /dev/null +++ b/tests/simulations/tragedy_of_the_commons/test_toc_environment.py @@ -0,0 +1,102 @@ +""" +Unit tests for the CommonsEnvironment in the Tragedy of the Commons simulation. + +This file tests the environment's core functionalities, such as grid management, +agent placement, and resource queries, ensuring the simulation world behaves +as expected. +""" + +import numpy as np +import pytest +from unittest.mock import MagicMock +from simulations.tragedy_of_the_commons.environment import CommonsEnvironment +from simulations.tragedy_of_the_commons.components import ResourceComponent + + +@pytest.fixture +def mock_rng(): + """Provides a mock NumPy random number generator.""" + return np.random.default_rng(42) + + +@pytest.fixture +def commons_env(mock_rng: np.random.Generator) -> CommonsEnvironment: + """Provides a standard CommonsEnvironment instance for tests.""" + return CommonsEnvironment(width=10, height=10, rng=mock_rng) + + +class TestCommonsEnvironment: + """Tests for the CommonsEnvironment class.""" + + def test_initialization(self, commons_env: CommonsEnvironment): + """Test that the environment initializes with the correct dimensions.""" + assert commons_env.width == 10 + assert commons_env.height == 10 + assert isinstance(commons_env.rng, np.random.Generator) + assert not commons_env.agent_positions + assert not commons_env.resource_grid + + def test_update_and_remove_entity(self, commons_env: CommonsEnvironment): + """Test adding, updating, and removing an agent's position.""" + # Add entity + commons_env.update_entity_position("agent_1", None, (5, 5)) + assert commons_env.agent_positions["agent_1"] == (5, 5) + assert commons_env.is_occupied_by_agent((5, 5)) + + # Update entity position + commons_env.update_entity_position("agent_1", (5, 5), (5, 6)) + assert commons_env.agent_positions["agent_1"] == (5, 6) + assert not commons_env.is_occupied_by_agent((5, 5)) + assert commons_env.is_occupied_by_agent((5, 6)) + + # Remove entity + commons_env.remove_entity("agent_1") + assert "agent_1" not in commons_env.agent_positions + assert not commons_env.is_occupied_by_agent((5, 6)) + + def test_get_resource_at_with_mock_state(self, commons_env: CommonsEnvironment): + """Test querying resource levels using a mocked simulation state.""" + # Setup mock simulation state and component + mock_sim_state = MagicMock() + resource_comp = ResourceComponent( + current_resource=15.0, max_resource=20, regeneration_rate=0.1 + ) + mock_sim_state.get_component.return_value = resource_comp + + # Link the mock state to the environment + commons_env.simulation_state = mock_sim_state + commons_env.resource_grid[(3, 3)] = "grass_patch_1" + + # Test getting resource + assert commons_env.get_resource_at((3, 3)) == 15.0 + mock_sim_state.get_component.assert_called_with( + "grass_patch_1", ResourceComponent + ) + + # Test getting resource from an empty location + assert commons_env.get_resource_at((4, 4)) == 0.0 + + def test_consume_resource_with_mock_state(self, commons_env: CommonsEnvironment): + """Test consuming resources using a mocked simulation state.""" + mock_sim_state = MagicMock() + resource_comp = ResourceComponent( + current_resource=15.0, max_resource=20, regeneration_rate=0.1 + ) + mock_sim_state.get_component.return_value = resource_comp + + commons_env.simulation_state = mock_sim_state + commons_env.resource_grid[(3, 3)] = "grass_patch_1" + + consumed = commons_env.consume_resource((3, 3), 10.0) + assert consumed == 10.0 + assert resource_comp.current_resource == 5.0 + + def test_get_all_empty_cells(self, commons_env: CommonsEnvironment): + """Test finding all empty cells on the grid.""" + commons_env.update_entity_position("agent_1", None, (0, 0)) + commons_env.update_entity_position("agent_2", None, (0, 1)) + empty_cells = commons_env.get_all_empty_cells() + assert len(empty_cells) == 98 + assert (0, 0) not in empty_cells + assert (0, 1) not in empty_cells + assert (1, 1) in empty_cells diff --git a/tests/simulations/tragedy_of_the_commons/test_toc_providers.py b/tests/simulations/tragedy_of_the_commons/test_toc_providers.py new file mode 100644 index 0000000..91fd484 --- /dev/null +++ b/tests/simulations/tragedy_of_the_commons/test_toc_providers.py @@ -0,0 +1,162 @@ +""" +Unit tests for the provider classes in the Tragedy of the Commons simulation. + +This file tests the logic of action generators, decision selectors, and state +encoders to ensure they produce the correct outputs based on a given +simulation state. +""" + +import numpy as np +import pytest +from unittest.mock import MagicMock +from simulations.tragedy_of_the_commons.providers import ( + HeuristicDecisionSelector, + CommonsStateEncoder, +) +from simulations.tragedy_of_the_commons.environment import CommonsEnvironment +from simulations.tragedy_of_the_commons.components import ( + EnergyComponent, + PositionComponent, +) +from agent_core.core.ecs.component import ActionPlanComponent + + +@pytest.fixture +def mock_sim_state_provider(): + """Provides a mock SimulationState for provider tests.""" + state = MagicMock() + # Make the mock environment identifiable as a CommonsEnvironment + state.environment = MagicMock(spec=CommonsEnvironment) + state.config = MagicMock() + # Correctly nest 'max_resource_per_patch' under 'params' to match the actual config structure. + state.config.environment.params.max_resource_per_patch = 20.0 + return state + + +class TestHeuristicDecisionSelector: + """Tests for the HeuristicDecisionSelector.""" + + @pytest.fixture + def selector(self) -> HeuristicDecisionSelector: + """Provides a HeuristicDecisionSelector instance.""" + # Pass mock objects, similar to how it's done in the run.py + return HeuristicDecisionSelector(simulation_state=None, config=None) + + def test_prefers_graze_action(self, selector: HeuristicDecisionSelector): + """Test that the selector chooses to graze if possible.""" + graze_action = ActionPlanComponent(action_type=MagicMock(action_id="graze")) + move_action = ActionPlanComponent(action_type=MagicMock(action_id="move")) + possible_actions = [move_action, graze_action] + + selected = selector.select(MagicMock(), "agent_1", possible_actions) + assert selected is not None + assert selected.action_type.action_id == "graze" + + def test_prefers_move_to_best_patch_if_no_graze( + self, selector: HeuristicDecisionSelector, mock_sim_state_provider + ): + """Test that the selector moves towards the richest patch if it cannot graze.""" + + # Setup environment mock to return different resource levels + def get_resource_at_side_effect(pos): + if pos == (1, 1): + return 10.0 # Best patch + if pos == (1, 2): + return 5.0 + return 0.0 + + mock_sim_state_provider.environment.get_resource_at.side_effect = ( + get_resource_at_side_effect + ) + + # Create move actions to different patches + move_to_best = ActionPlanComponent( + action_type=MagicMock(action_id="move"), params={"target_pos": (1, 1)} + ) + move_to_good = ActionPlanComponent( + action_type=MagicMock(action_id="move"), params={"target_pos": (1, 2)} + ) + wait_action = ActionPlanComponent(action_type=MagicMock(action_id="wait")) + possible_actions = [move_to_good, wait_action, move_to_best] + + selected = selector.select(mock_sim_state_provider, "agent_1", possible_actions) + assert selected is not None + # It should select the move action, which has params + assert "target_pos" in selected.params + assert selected.params["target_pos"] == (1, 1) + + def test_prefers_wait_if_no_good_move( + self, selector: HeuristicDecisionSelector, mock_sim_state_provider + ): + """Test that the selector waits if it cannot graze or find a better patch.""" + # Mock the environment to return 0 for all resource checks + mock_sim_state_provider.environment.get_resource_at.return_value = 0.0 + + move_action = ActionPlanComponent( + action_type=MagicMock(action_id="move"), params={"target_pos": (1, 1)} + ) + wait_action = ActionPlanComponent(action_type=MagicMock(action_id="wait")) + possible_actions = [move_action, wait_action] + + selected = selector.select(mock_sim_state_provider, "agent_1", possible_actions) + assert selected is not None + assert selected.action_type.action_id == "wait" + + +class TestCommonsStateEncoder: + """Tests for the CommonsStateEncoder.""" + + @pytest.fixture + def encoder(self) -> CommonsStateEncoder: + """Provides a CommonsStateEncoder instance.""" + return CommonsStateEncoder() + + def test_encode_state(self, encoder: CommonsStateEncoder, mock_sim_state_provider): + """Test the state encoding logic.""" + # Setup mocks + pos_comp = PositionComponent(x=5, y=5) + energy_comp = EnergyComponent(current_energy=80.0, initial_energy=100.0) + + # Configure get_component mock to return the correct component based on type for robustness. + def get_component_side_effect(entity_id, component_type): + if component_type is PositionComponent: + return pos_comp + if component_type is EnergyComponent: + return energy_comp + return MagicMock() # Return a default mock for any other type + + mock_sim_state_provider.get_component.side_effect = get_component_side_effect + + # Use a dictionary for a more robust side_effect + def get_resource_at_side_effect(pos): + resource_map = { + (5, 5): 10.0, # Center + (5, 4): 12.0, # North + (5, 6): 8.0, # South + (6, 5): 15.0, # East + (4, 5): 5.0, # West + } + return resource_map.get(pos, 0.0) + + mock_sim_state_provider.environment.get_resource_at.side_effect = ( + get_resource_at_side_effect + ) + + # Execute + # FIX: Pass the mocked config object to the encoder. + features = encoder.encode_state( + mock_sim_state_provider, "agent_1", mock_sim_state_provider.config + ) + + # Verify + assert isinstance(features, np.ndarray) + assert features.shape == (6,) + assert features.dtype == np.float32 + + # Expected values: [norm_energy, center, N, S, E, W] + # norm_energy = 80/100 = 0.8 + # resources are normalized by max_resource (20.0) + expected_features = np.array( + [0.8, 10 / 20, 12 / 20, 8 / 20, 15 / 20, 5 / 20], dtype=np.float32 + ) + np.testing.assert_allclose(features, expected_features, rtol=1e-6) diff --git a/tests/simulations/tragedy_of_the_commons/test_toc_systems.py b/tests/simulations/tragedy_of_the_commons/test_toc_systems.py new file mode 100644 index 0000000..fdbcdc1 --- /dev/null +++ b/tests/simulations/tragedy_of_the_commons/test_toc_systems.py @@ -0,0 +1,222 @@ +""" +Unit tests for the system classes in the Tragedy of the Commons simulation. + +This file tests the core logic of each system (Metabolism, Vitals, Movement, etc.) +to ensure they correctly modify component and environment state based on +the simulation rules. +""" + +import pytest +from unittest.mock import MagicMock + +# FIX: Use absolute imports to resolve the ImportError +from simulations.tragedy_of_the_commons.systems import ( + MetabolismSystem, + VitalsSystem, + MovementSystem, + GrazingSystem, + ResourceRegenerationSystem, +) +from simulations.tragedy_of_the_commons.components import ( + EnergyComponent, + PositionComponent, + ResourceComponent, +) +from simulations.tragedy_of_the_commons.environment import CommonsEnvironment +from agent_core.core.ecs.component import TimeBudgetComponent +from agent_core.agents.actions.action_outcome import ActionOutcome + +# --- Mocks and Fixtures --- + + +@pytest.fixture +def mock_sim_state(): + """Provides a mock SimulationState for system tests.""" + state = MagicMock() + state.environment = MagicMock(spec=CommonsEnvironment) + state.config = MagicMock() + state.event_bus = MagicMock() + # Mock entities as a dictionary + state.entities = {} + + def get_component(entity_id, comp_type): + return state.entities.get(entity_id, {}).get(comp_type) + + def get_entities_with_components(comp_types): + result = {} + for entity_id, components in state.entities.items(): + if all(comp_type in components for comp_type in comp_types): + result[entity_id] = components + return result + + state.get_component.side_effect = get_component + state.get_entities_with_components.side_effect = get_entities_with_components + + return state + + +# --- System Tests --- + + +@pytest.mark.asyncio +class TestMetabolismSystem: + """Tests for the MetabolismSystem.""" + + async def test_update_decreases_energy(self, mock_sim_state): + """Verify that the system correctly applies metabolic cost.""" + # Arrange + mock_sim_state.config.agent.metabolic_cost_per_tick = 0.5 + agent_id = "herder_1" + energy_comp = EnergyComponent(current_energy=100.0, initial_energy=100.0) + time_comp = TimeBudgetComponent(initial_time_budget=100) + mock_sim_state.entities[agent_id] = { + EnergyComponent: energy_comp, + TimeBudgetComponent: time_comp, + } + + system = MetabolismSystem(mock_sim_state, mock_sim_state.config, None) + + # Act + await system.update(current_tick=1) + + # Assert + assert energy_comp.current_energy == 99.5 + + async def test_update_does_not_affect_inactive_agents(self, mock_sim_state): + """Verify that inactive agents do not consume energy.""" + # Arrange + mock_sim_state.config.agent.metabolic_cost_per_tick = 0.5 + agent_id = "herder_1" + energy_comp = EnergyComponent(current_energy=100.0, initial_energy=100.0) + time_comp = TimeBudgetComponent(initial_time_budget=100) + time_comp.is_active = False # Agent is inactive + mock_sim_state.entities[agent_id] = { + EnergyComponent: energy_comp, + TimeBudgetComponent: time_comp, + } + + system = MetabolismSystem(mock_sim_state, mock_sim_state.config, None) + + # Act + await system.update(current_tick=1) + + # Assert + assert energy_comp.current_energy == 100.0 + + +@pytest.mark.asyncio +class TestVitalsSystem: + """Tests for the VitalsSystem.""" + + async def test_deactivates_agent_at_zero_energy(self, mock_sim_state): + """Verify agents are deactivated when their energy reaches zero.""" + # Arrange + agent_id = "herder_1" + energy_comp = EnergyComponent(current_energy=0.0, initial_energy=100.0) + time_comp = TimeBudgetComponent(initial_time_budget=100) + mock_sim_state.entities[agent_id] = { + EnergyComponent: energy_comp, + TimeBudgetComponent: time_comp, + } + + system = VitalsSystem(mock_sim_state, mock_sim_state.config, None) + + # Act + await system.update(current_tick=1) + + # Assert + assert not time_comp.is_active + mock_sim_state.environment.remove_entity.assert_called_with(agent_id) + mock_sim_state.event_bus.publish.assert_called_with( + "agent_deactivated", + {"entity_id": agent_id, "current_tick": 1}, + ) + + +class TestMovementSystem: + """Tests for the MovementSystem.""" + + def test_on_move_updates_position(self, mock_sim_state): + """Verify a successful move updates the PositionComponent and environment.""" + # Arrange + agent_id = "herder_1" + pos_comp = PositionComponent(x=5, y=5) + mock_sim_state.entities[agent_id] = {PositionComponent: pos_comp} + mock_sim_state.environment.is_valid_position.return_value = True + mock_sim_state.environment.is_occupied_by_agent.return_value = False + + event_data = { + "entity_id": agent_id, + "action_plan_component": MagicMock(params={"target_pos": (6, 5)}), + } + + system = MovementSystem(mock_sim_state, mock_sim_state.config, None) + + # Act + system.on_move(event_data) + + # Assert + assert pos_comp.position == (6, 5) + mock_sim_state.environment.update_entity_position.assert_called_with( + agent_id, (5, 5), (6, 5) + ) + mock_sim_state.event_bus.publish.assert_called_with( + "action_outcome_ready", event_data + ) + + +class TestGrazingSystem: + """Tests for the GrazingSystem.""" + + def test_on_graze_updates_energy_and_resource(self, mock_sim_state): + """Verify grazing increases agent energy and decreases patch resources.""" + # Arrange + agent_id = "herder_1" + pos_comp = PositionComponent(x=5, y=5) + energy_comp = EnergyComponent(current_energy=50.0, initial_energy=100.0) + mock_sim_state.entities[agent_id] = { + PositionComponent: pos_comp, + EnergyComponent: energy_comp, + } + mock_sim_state.config.agent.graze_amount = 5.0 + # Mock environment returns 5.0 resources consumed + mock_sim_state.environment.consume_resource.return_value = 5.0 + + event_data = {"entity_id": agent_id, "action_plan_component": MagicMock()} + + system = GrazingSystem(mock_sim_state, mock_sim_state.config, None) + + # Act + system.on_graze(event_data) + + # Assert + assert energy_comp.current_energy == 55.0 + mock_sim_state.environment.consume_resource.assert_called_with((5, 5), 5.0) + # Check that the reward in the outcome matches the consumed amount + outcome = event_data["action_outcome"] + assert isinstance(outcome, ActionOutcome) + assert outcome.base_reward == 5.0 + + +@pytest.mark.asyncio +class TestResourceRegenerationSystem: + """Tests for the ResourceRegenerationSystem.""" + + async def test_regenerates_resources(self, mock_sim_state): + """Verify that the system calls regenerate on resource components.""" + # Arrange + res_comp1 = MagicMock(spec=ResourceComponent) + res_comp2 = MagicMock(spec=ResourceComponent) + mock_sim_state.entities = { + "grass_1": {ResourceComponent: res_comp1}, + "grass_2": {ResourceComponent: res_comp2}, + } + + system = ResourceRegenerationSystem(mock_sim_state, mock_sim_state.config, None) + + # Act + await system.update(current_tick=1) + + # Assert + res_comp1.regenerate.assert_called_once() + res_comp2.regenerate.assert_called_once()