From 0652ce8663373c74539f16002689a86e8ca09af1 Mon Sep 17 00:00:00 2001 From: Elazar Gershuni Date: Sun, 1 Feb 2026 00:23:59 +0200 Subject: [PATCH 1/5] migrate to docker-compose --- .dockerignore | 36 ++++ CLAUDE.md | 167 +++++++++--------- README.md | 68 ++++---- app/config.py | 18 +- app/core/plugin_manager.py | 316 +++++++---------------------------- app/main.py | 16 +- app/plugins/__init__.py | 12 +- app/static_mount.py | 4 + docker-compose.yml | 107 ++++++++++++ docker/Dockerfile.app | 22 +++ docker/Dockerfile.base | 20 +++ docker/Dockerfile.egttools | 17 ++ docker/Dockerfile.gambit | 22 +++ docker/Dockerfile.openspiel | 25 +++ docker/Dockerfile.pycid | 29 ++++ docker/Dockerfile.vegas | 16 ++ plugins.toml | 45 +---- scripts/create_venv.bat | 11 -- scripts/create_venv.ps1 | 19 --- scripts/restart.ps1 | 23 --- scripts/restart.sh | 15 -- scripts/run-all-tests.ps1 | 45 ----- scripts/setup-plugins.ps1 | 86 ---------- scripts/setup-plugins.sh | 78 --------- tests/test_plugin_manager.py | 86 +++++----- 25 files changed, 557 insertions(+), 746 deletions(-) create mode 100644 .dockerignore create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.app create mode 100644 docker/Dockerfile.base create mode 100644 docker/Dockerfile.egttools create mode 100644 docker/Dockerfile.gambit create mode 100644 docker/Dockerfile.openspiel create mode 100644 docker/Dockerfile.pycid create mode 100644 docker/Dockerfile.vegas delete mode 100644 scripts/create_venv.bat delete mode 100644 scripts/create_venv.ps1 delete mode 100644 scripts/restart.ps1 delete mode 100644 scripts/restart.sh delete mode 100644 scripts/run-all-tests.ps1 delete mode 100644 scripts/setup-plugins.ps1 delete mode 100644 scripts/setup-plugins.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..86452e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.venv/ +plugins/*/.venv/ +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ + +# Node +frontend/node_modules/ +frontend/dist/ +frontend/.parcel-cache/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git/ +.gitignore + +# Docs (tests included for container testing) +*.md +!README.md + +# Logs and temp files +*.log +*.tmp diff --git a/CLAUDE.md b/CLAUDE.md index 7e3e0f6..64d15de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,132 +2,127 @@ Instructions for Claude Code when working on this project. -## Python Environment +## Docker Development -This project uses a virtual environment at `.venv/`. Always use it: +This project uses Docker Compose to run the backend and plugins. Frontend runs separately via npm. -```bash -# Run Python commands through the venv -.venv/Scripts/python -m pytest tests/ -v -.venv/Scripts/pip install -e ".[dev]" - -# Or activate first (Windows) -.venv\Scripts\activate - -# Or activate first (Unix) -source .venv/bin/activate -``` - -**NEVER** use system Python or `pip install` directly. Always use `.venv/Scripts/pip`. - -Important: use correct slashes - usually "/" is the right one. +### Starting the Backend -## Dependency Management - -### Python (Backend) - -Dependencies are defined in `pyproject.toml`: -- Runtime deps in `[project.dependencies]` -- Dev deps in `[project.optional-dependencies.dev]` +```bash +# Build all images (first time or after code changes) +docker compose build -To add a new dependency: -1. Add it to `pyproject.toml` first -2. Then install: `.venv/Scripts/pip install -e ".[dev]"` +# Start all services +docker compose up -**NEVER** run `pip install ` directly without adding to pyproject.toml first. +# Start in background +docker compose up -d -### JavaScript (Frontend) +# View logs +docker compose logs -f app -Dependencies are defined in `frontend/package.json`. +# Stop all services +docker compose down +``` -To add a new dependency: -1. Add it to `package.json` under `dependencies` or `devDependencies` -2. Use `"latest"` if unsure of version, then lock to resolved version -3. Run `npm install` from the `frontend/` directory +### Starting the Frontend -**NEVER** run `npm install ` directly without adding to package.json first. +```bash +cd frontend +npm install # first time only +npm run dev # starts dev server on http://localhost:5173 +``` -## Running Tests +### Rebuilding After Changes ```bash -# Main app tests (140 pass, 0 skipped) -.venv/Scripts/python -m pytest tests/ -v --tb=short --ignore=tests/integration +# Rebuild specific service +docker compose build gambit && docker compose up -d gambit -# Gambit plugin tests (76 tests, run from plugin venv) -plugins/gambit/.venv/Scripts/python -m pytest plugins/gambit/tests/ -v +# Rebuild all services +docker compose build && docker compose up -d +``` -# PyCID plugin tests (run from plugin venv) -plugins/pycid/.venv/Scripts/python -m pytest plugins/pycid/tests/ -v +### Running Tests -# Integration tests (requires plugin venvs; plugins start automatically) -.venv/Scripts/python -m pytest tests/integration/ -v --tb=short +```bash +# Run main app tests inside container +docker compose exec app pytest tests/ -v --tb=short --ignore=tests/integration -# All test suites (Windows) -scripts/run-all-tests.ps1 +# Run integration tests (requires all services running) +docker compose exec app pytest tests/integration/ -v --tb=short # Frontend build (includes TypeScript check) cd frontend && npm run build ``` -Tests for pygambit-dependent analysis (Nash, IESDS, profile verification, EFG/NFG -parsing) live in `plugins/gambit/tests/` and run in the gambit plugin venv. -Integration tests in `tests/integration/` verify the full main app → plugin HTTP flow. - ## Plugin Architecture -Analysis plugins (Gambit, PyCID) run as isolated FastAPI subprocesses, each in its -own virtual environment. This avoids dependency conflicts (e.g. PyCID needs -`pgmpy==0.1.17` + `pygambit>=16.3.2`, while Gambit analyses use `pygambit==16.5.0`). - -### Setting Up Plugin Venvs - -```bash -# Windows -scripts/setup-plugins.ps1 - -# Unix -scripts/setup-plugins.sh -``` - -This creates `plugins/gambit/.venv/` and `plugins/pycid/.venv/` with their respective -dependencies. Plugin venvs are gitignored. +Analysis plugins (Gambit, PyCID, Vegas, EGTTools, OpenSpiel) run as Docker containers +managed by Docker Compose. This avoids dependency conflicts (e.g., PyCID needs +`pgmpy==0.1.17` + `pygambit>=16.3.2`, while Gambit analyses use `pygambit`). ### How It Works -- `plugins.toml` defines plugin subprocess commands, working directories, and restart policies -- On app startup, `PluginManager` launches each plugin on a dynamic port via `subprocess.Popen` +- `docker-compose.yml` defines all services and their health checks +- `plugins.toml` lists plugin names; URLs come from environment variables +- On app startup, `PluginManager` health-checks each plugin container - Each plugin exposes `GET /health`, `GET /info`, `POST /analyze`, `POST /parse/{format}`, `GET /tasks/{id}`, `POST /cancel/{id}` - `RemotePlugin` adapter makes HTTP plugins look like local `AnalysisPlugin` instances -- Plugins that advertise format support (e.g. `.efg`, `.nfg`) get registered as format parsers via `app/formats/remote.py` -- If a plugin isn't running, its analyses and formats are unavailable (graceful degradation) +- Plugins that advertise format support (e.g., `.efg`, `.nfg`) get registered as format parsers +- If a plugin container isn't healthy, its analyses and formats are unavailable (graceful degradation) ### Plugin HTTP Contract -See `plugins.toml` for configuration. Each plugin is a FastAPI app implementing API v1. +Each plugin is a FastAPI app implementing API v1. See individual plugin Dockerfiles in `docker/`. -## Starting the App +### Service Ports -```bash -# Backend (plugins start automatically if venvs are set up) -.venv/Scripts/python -m uvicorn app.main:app --reload +| Service | Internal Port | External Port | +|------------|---------------|---------------| +| Main App | 8000 | 8000 | +| Gambit | 5001 | 5001 | +| PyCID | 5002 | 5002 | +| EGTTools | 5003 | 5003 | +| Vegas | 5004 | 5004 | +| OpenSpiel | 5005 | 5005 | -# Frontend dev server -cd frontend && npm run dev -``` +## Dependency Management + +### Python (Backend) + +Dependencies are defined in `pyproject.toml` and plugin-specific `pyproject.toml` files. +Docker images install dependencies during build. + +To add a new dependency: +1. Add it to the appropriate `pyproject.toml` +2. Rebuild the Docker image: `docker compose build ` + +### JavaScript (Frontend) + +Dependencies are defined in `frontend/package.json`. + +To add a new dependency: +1. Add it to `package.json` under `dependencies` or `devDependencies` +2. Run `npm install` from the `frontend/` directory ## Project Structure - `app/` - FastAPI backend (thin orchestrator) - `app/formats/` - Format registry and JSON parser; gambit formats registered dynamically from plugin - `app/formats/remote.py` - Proxy format parsing to remote plugins via HTTP -- `app/plugins/` - Local plugins (validation, dominance) — no external deps -- `app/core/plugin_manager.py` - Subprocess supervisor for remote plugins +- `app/plugins/` - Local plugins (validation, dominance) - no external deps +- `app/core/plugin_manager.py` - Plugin discovery and health-checking for Docker containers - `app/core/remote_plugin.py` - HTTP adapter for remote plugins -- `plugins/gambit/` - Gambit plugin service (pygambit, own venv) -- `plugins/pycid/` - PyCID plugin service (pycid, pgmpy, own venv) -- `plugins.toml` - Plugin configuration +- `plugins/gambit/` - Gambit plugin service (pygambit) +- `plugins/pycid/` - PyCID plugin service (pycid, pgmpy) +- `plugins/vegas/` - Vegas DSL plugin service +- `plugins/egttools/` - Evolutionary game theory plugin service +- `plugins/openspiel/` - OpenSpiel plugin service (CFR, exploitability) +- `docker/` - Dockerfiles for all services +- `docker-compose.yml` - Service orchestration +- `plugins.toml` - Plugin configuration (names only; URLs from environment) - `frontend/` - React + Pixi.js frontend -- `tests/` - Python tests (main app, 140 tests) -- `tests/integration/` - Integration tests (main app + plugins, 8 tests) +- `tests/` - Python tests (main app) +- `tests/integration/` - Integration tests (main app + plugins) - `examples/` - Sample game files (.efg, .nfg, .json) -- `scripts/` - Setup and utility scripts diff --git a/README.md b/README.md index 4f17a9d..f988951 100644 --- a/README.md +++ b/README.md @@ -15,29 +15,27 @@ A canvas-first game theory analysis and visualization workbench. Build, visualiz ### Prerequisites -- Python 3.12+ -- Node.js 18+ -- Rust (for building pygambit) +- Docker and Docker Compose +- Node.js 18+ (for frontend development) -### Backend Setup +### Backend Setup (Docker) ```bash -# Create and activate virtual environment -py -3.12 -m venv .venv -.venv\Scripts\activate # Windows -# source .venv/bin/activate # Unix +# Build all images (first time or after code changes) +docker compose build -# Install dependencies -pip install -e ".[dev]" +# Start all services +docker compose up -d -# Set up analysis plugins (Nash equilibrium, IESDS) -scripts/setup-plugins.ps1 # Windows -# scripts/setup-plugins.sh # Unix +# View logs +docker compose logs -f app -# Start the server -uvicorn app.main:app --reload +# Stop all services +docker compose down ``` +The backend API will be available at http://localhost:8000. + ### Frontend Setup ```bash @@ -57,20 +55,21 @@ thrones/ │ ├── models/ # Game data models │ ├── plugins/ # Local analysis plugins │ └── core/ # Store, registry, task manager -├── plugins/ # Remote analysis plugins (isolated venvs) +├── plugins/ # Remote analysis plugins (Docker containers) │ ├── gambit/ # Nash, IESDS, EFG/NFG parsing (pygambit) │ ├── pycid/ # MAID Nash, strategic relevance (pycid) │ ├── vegas/ # Vegas DSL parsing (.vg files) │ ├── egttools/ # Evolutionary dynamics (replicator, fixation) -│ └── openspiel/ # CFR, exploitability (Linux/macOS only) +│ └── openspiel/ # CFR, exploitability +├── docker/ # Dockerfiles for all services +├── docker-compose.yml # Service orchestration ├── frontend/ # React + Pixi.js UI │ └── src/ │ ├── canvas/ # Game visualization │ ├── components/ # UI components │ └── stores/ # State management ├── examples/ # Sample game files -├── tests/ # Test suites -└── docs/ # Documentation +└── tests/ # Test suites ``` ## Documentation @@ -87,14 +86,11 @@ thrones/ ## Running Tests ```bash -# Backend tests -.venv/Scripts/python -m pytest tests/ -v --ignore=tests/integration - -# Plugin tests (run from plugin venv) -plugins/gambit/.venv/Scripts/python -m pytest plugins/gambit/tests/ -v +# Run main app tests inside container +docker compose exec app pytest tests/ -v --tb=short --ignore=tests/integration -# Integration tests -.venv/Scripts/python -m pytest tests/integration/ -v +# Run integration tests (requires all services running) +docker compose exec app pytest tests/integration/ -v --tb=short # Frontend build (includes TypeScript check) cd frontend && npm run build @@ -119,18 +115,30 @@ The `examples/` directory contains sample games in various formats: ## Technology Stack -- **Backend**: FastAPI, Pydantic +- **Backend**: FastAPI, Pydantic, Docker Compose - **Frontend**: React 19, Pixi.js 8, Zustand, TypeScript -- **Analysis Plugins**: +- **Analysis Plugins** (Docker containers): - **Gambit**: Nash equilibrium, IESDS, EFG/NFG parsing (pygambit) - **PyCID**: MAID Nash equilibrium, strategic relevance analysis (pycid) - **Vegas**: Vegas DSL game description language - **EGTTools**: Evolutionary dynamics, replicator equations, fixation probabilities - - **OpenSpiel**: CFR, exploitability (Linux/macOS only) + - **OpenSpiel**: CFR, exploitability + +## Service Ports + +| Service | Port | +|------------|------| +| Main App | 8000 | +| Gambit | 5001 | +| PyCID | 5002 | +| EGTTools | 5003 | +| Vegas | 5004 | +| OpenSpiel | 5005 | +| Frontend | 5173 | ## Current Version -**v0.5.0** - Plugin ecosystem with 5 remote analysis plugins. +**v0.5.0** - Plugin ecosystem with 5 remote analysis plugins running as Docker containers. ## License diff --git a/app/config.py b/app/config.py index 6542519..7494e5c 100644 --- a/app/config.py +++ b/app/config.py @@ -5,9 +5,21 @@ """ from __future__ import annotations +import os + + +# Plugin URLs from environment variables (for Docker Compose) +PLUGIN_URLS: dict[str, str] = { + "gambit": os.environ.get("GAMBIT_URL", "http://gambit:5001"), + "pycid": os.environ.get("PYCID_URL", "http://pycid:5002"), + "egttools": os.environ.get("EGTTOOLS_URL", "http://egttools:5003"), + "vegas": os.environ.get("VEGAS_URL", "http://vegas:5004"), + "openspiel": os.environ.get("OPENSPIEL_URL", "http://openspiel:5005"), +} + class PluginManagerConfig: - """Configuration constants for plugin process management.""" + """Configuration constants for plugin discovery and health-checking.""" # Startup and health check # PyCID plugin takes ~30s to import libraries on first load @@ -15,10 +27,6 @@ class PluginManagerConfig: HEALTH_CHECK_TIMEOUT_SECONDS = 2.0 INFO_FETCH_TIMEOUT_SECONDS = 5.0 - # Restart policy - MAX_RESTARTS = 3 - MAX_PORT_RETRIES = 3 - # Polling intervals HEALTH_CHECK_INITIAL_INTERVAL = 0.1 HEALTH_CHECK_MAX_INTERVAL = 1.0 diff --git a/app/core/plugin_manager.py b/app/core/plugin_manager.py index 4270f11..68e5def 100644 --- a/app/core/plugin_manager.py +++ b/app/core/plugin_manager.py @@ -1,16 +1,11 @@ -"""Subprocess supervisor for remote plugin services. +"""Plugin discovery and health-checking for Docker Compose-managed services. -Manages plugin processes: launches them on dynamic ports, health-checks, -restarts on failure, and shuts down cleanly. +Discovers plugins by health-checking predefined URLs (from environment variables). +Plugins are managed by Docker Compose, not as subprocesses. """ from __future__ import annotations import logging -import os -import signal -import socket -import subprocess -import sys import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field @@ -20,51 +15,34 @@ import httpx import tomllib -from app.config import PluginManagerConfig +from app.config import PluginManagerConfig, PLUGIN_URLS logger = logging.getLogger(__name__) -_IS_WINDOWS = sys.platform == "win32" - @dataclass class PluginConfig: """Configuration for a single remote plugin.""" name: str - command: list[str] - cwd: str = "." - auto_start: bool = True - restart: str = "on-failure" # never | on-failure | always - skip_on_windows: bool = False # If True, don't start on Windows + url: str = "" @dataclass class PluginProcess: - """Runtime state for a managed plugin process.""" + """Runtime state for a plugin service (Docker container).""" config: PluginConfig - port: int = 0 - process: subprocess.Popen | None = None url: str = "" healthy: bool = False - restart_count: int = 0 info: dict[str, Any] = field(default_factory=dict) analyses: list[dict[str, Any]] = field(default_factory=list) -def _find_free_port() -> int: - """Ask the OS for a free TCP port.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - def load_plugins_toml(path: str | Path) -> tuple[dict[str, Any], list[PluginConfig]]: """Load and parse plugins.toml, returning (settings, plugin_configs). - Supports platform-specific commands via `command_windows` field. - On Windows, uses `command_windows` if present, otherwise `command`. + In Docker mode, we only need plugin names - URLs come from environment. """ path = Path(path) if not path.exists(): @@ -76,43 +54,24 @@ def load_plugins_toml(path: str | Path) -> tuple[dict[str, Any], list[PluginConf settings = data.get("settings", {}) plugins = [] for entry in data.get("plugins", []): - # Skip plugins marked for Windows exclusion - skip_on_windows = entry.get("skip_on_windows", False) - if _IS_WINDOWS and skip_on_windows: - logger.info("Skipping plugin %s on Windows (skip_on_windows=true)", entry["name"]) - continue - - # Select platform-appropriate command - if _IS_WINDOWS and "command_windows" in entry: - command = entry["command_windows"] - else: - command = entry["command"] - - plugins.append( - PluginConfig( - name=entry["name"], - command=command, - cwd=entry.get("cwd", "."), - auto_start=entry.get("auto_start", True), - restart=entry.get("restart", "on-failure"), - skip_on_windows=skip_on_windows, - ) - ) + name = entry["name"] + # URL comes from environment variables only + url = PLUGIN_URLS.get(name, "") + plugins.append(PluginConfig(name=name, url=url)) + return settings, plugins class PluginManager: - """Manages remote plugin subprocess lifecycles.""" + """Discovers and health-checks Docker Compose-managed plugin services.""" def __init__( self, config_path: str | Path | None = None, startup_timeout: float = PluginManagerConfig.STARTUP_TIMEOUT_SECONDS, - max_restarts: int = PluginManagerConfig.MAX_RESTARTS, ): self._config_path = config_path self._startup_timeout = startup_timeout - self._max_restarts = max_restarts self._plugins: dict[str, PluginProcess] = {} self._project_root: Path = Path.cwd() self._loading: bool = False @@ -120,7 +79,7 @@ def __init__( self._startup_results: dict[str, bool] = {} def load_config(self, project_root: Path | None = None) -> None: - """Load plugin configuration from plugins.toml.""" + """Load plugin configuration from plugins.toml and environment.""" if project_root: self._project_root = project_root @@ -130,144 +89,77 @@ def load_config(self, project_root: Path | None = None) -> None: self._startup_timeout = settings.get( "startup_timeout_seconds", self._startup_timeout ) - self._max_restarts = settings.get("max_restarts", self._max_restarts) for pc in plugin_configs: - self._plugins[pc.name] = PluginProcess(config=pc) + pp = PluginProcess(config=pc, url=pc.url) + self._plugins[pc.name] = pp def start_all(self, background: bool = False) -> dict[str, bool]: - """Start all auto_start plugins in parallel. Returns {name: success}. + """Discover all plugins by health-checking Docker containers. - If background=True, returns immediately and starts plugins in a thread. + If background=True, returns immediately and discovers plugins in a thread. Check is_loading and loading_status for progress. """ - to_start = {name: pp for name, pp in self._plugins.items() if pp.config.auto_start} - - if not to_start: - return {name: False for name in self._plugins} + if not self._plugins: + return {} self._loading = True - self._loading_plugins = set(to_start.keys()) + self._loading_plugins = set(self._plugins.keys()) self._startup_results = {} - def _do_start(): - # Start plugins in parallel to reduce total startup time - with ThreadPoolExecutor(max_workers=len(to_start)) as executor: - futures = {name: executor.submit(self._start_plugin_tracked, name, pp) - for name, pp in to_start.items()} + def _do_discover(): + # Check plugins in parallel for faster startup + with ThreadPoolExecutor(max_workers=len(self._plugins)) as executor: + futures = { + name: executor.submit(self._discover_plugin_tracked, name, pp) + for name, pp in self._plugins.items() + } for name, fut in futures.items(): self._startup_results[name] = fut.result() - # Add non-auto-start plugins as False - for name in self._plugins: - if name not in self._startup_results: - self._startup_results[name] = False - self._loading = False if background: import threading - thread = threading.Thread(target=_do_start, daemon=True) + thread = threading.Thread(target=_do_discover, daemon=True) thread.start() return {} # Results not available yet else: - _do_start() + _do_discover() return self._startup_results - def _start_plugin_tracked(self, name: str, pp: PluginProcess) -> bool: - """Start plugin and update loading state.""" + def _discover_plugin_tracked(self, name: str, pp: PluginProcess) -> bool: + """Discover plugin and update loading state.""" try: - return self._start_plugin(pp) + return self._discover_plugin(pp) finally: self._loading_plugins.discard(name) - def _start_plugin( - self, pp: PluginProcess, max_port_retries: int = PluginManagerConfig.MAX_PORT_RETRIES - ) -> bool: - """Start a single plugin subprocess and wait for health. + def _discover_plugin(self, pp: PluginProcess) -> bool: + """Health-check a plugin and fetch its info if healthy.""" + if not pp.url: + logger.warning("No URL configured for plugin %s", pp.config.name) + return False - Retries with fresh port allocation to mitigate TOCTOU race conditions - where another process grabs the port between allocation and binding. - """ - cwd = self._project_root / pp.config.cwd - - for attempt in range(max_port_retries): - port = _find_free_port() - pp.port = port - pp.url = f"http://127.0.0.1:{port}" - - # Build the command with --port argument - raw_cmd = list(pp.config.command) + [f"--port={port}"] - - # Resolve the executable path relative to project root so it works - # regardless of the parent process's working directory. - # BUT: if the first element is a bare command (no path separators), - # it's a system command like 'wsl', 'python', etc. - don't resolve it. - first = raw_cmd[0] - if "/" in first or "\\" in first: - # Project-relative path - resolve to absolute - exe_path = self._project_root / first - cmd = [str(exe_path)] + raw_cmd[1:] - else: - # Bare system command (wsl, python, etc.) - use as-is - cmd = raw_cmd + logger.info("Discovering plugin %s at %s", pp.config.name, pp.url) + # Wait for health + health_result = self._wait_for_health(pp) + if health_result is True: + pp.healthy = True + self._fetch_info(pp) logger.info( - "Starting plugin %s on port %d: %s (cwd=%s)", - pp.config.name, port, " ".join(cmd), cwd, + "Plugin %s healthy at %s (%d analyses)", + pp.config.name, pp.url, len(pp.analyses), ) + return True - try: - creation_flags = 0 - if _IS_WINDOWS: - creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP - - # Use DEVNULL for stdout/stderr to prevent pipe buffer deadlocks. - # If a plugin writes too much output without the parent reading, - # the pipe buffers fill up and the plugin blocks on writes, - # making it unresponsive to health checks. - pp.process = subprocess.Popen( - cmd, - cwd=str(cwd), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=creation_flags, - ) - except (FileNotFoundError, OSError) as e: - logger.error("Failed to launch plugin %s: %s", pp.config.name, e) - return False - - # Wait for health - health_result = self._wait_for_health(pp) - if health_result is True: - pp.healthy = True - self._fetch_info(pp) - logger.info( - "Plugin %s healthy on port %d (%d analyses)", - pp.config.name, port, len(pp.analyses), - ) - return True - - if health_result == "degraded": - # Plugin is running but reports error (e.g., platform not supported) - # Don't retry - this is not a port conflict, plugin started successfully - # but is intentionally non-functional. Keep process running for status queries. - pp.healthy = False - logger.info( - "Plugin %s started in degraded mode on port %d", - pp.config.name, port, - ) - return True # Return True so we don't retry ports - - # Health check failed - could be port conflict (TOCTOU race) - self._kill(pp) - if attempt < max_port_retries - 1: - logger.warning( - "Plugin %s failed on port %d, retrying with new port (attempt %d/%d)", - pp.config.name, port, attempt + 2, max_port_retries, - ) + if health_result == "degraded": + pp.healthy = False + logger.info("Plugin %s started in degraded mode at %s", pp.config.name, pp.url) + return True # Still counts as "discovered" - logger.error("Plugin %s failed after %d attempts", pp.config.name, max_port_retries) + logger.warning("Plugin %s not reachable at %s", pp.config.name, pp.url) return False def _wait_for_health(self, pp: PluginProcess, timeout: float | None = None) -> bool | str: @@ -282,14 +174,6 @@ def _wait_for_health(self, pp: PluginProcess, timeout: float | None = None) -> b interval = PluginManagerConfig.HEALTH_CHECK_INITIAL_INTERVAL while time.monotonic() < deadline: - # Check process is still alive - if pp.process and pp.process.poll() is not None: - logger.warning( - "Plugin %s exited with code %d during startup", - pp.config.name, pp.process.returncode, - ) - return False - try: resp = httpx.get( f"{pp.url}/health", @@ -300,7 +184,6 @@ def _wait_for_health(self, pp: PluginProcess, timeout: float | None = None) -> b if data.get("status") == "ok" and data.get("api_version") == 1: return True # Plugin explicitly reports error status (e.g., platform not supported) - # This is a valid response - plugin is running but not functional if data.get("status") == "error": error_msg = data.get("error", "Unknown error") logger.warning( @@ -314,12 +197,10 @@ def _wait_for_health(self, pp: PluginProcess, timeout: float | None = None) -> b pp.config.name, data, ) except (httpx.ConnectError, httpx.TimeoutException): - # Expected during startup - plugin not ready yet + # Expected during startup - container not ready yet logger.debug("Plugin %s not ready yet (connection/timeout)", pp.config.name) except httpx.HTTPStatusError as e: - logger.debug( - "Health check HTTP error for %s: %s", pp.config.name, e - ) + logger.debug("Health check HTTP error for %s: %s", pp.config.name, e) time.sleep(interval) interval = min( @@ -344,82 +225,8 @@ def _fetch_info(self, pp: PluginProcess) -> None: except httpx.HTTPStatusError as e: logger.warning("HTTP error fetching /info from %s: %s", pp.config.name, e) - def _kill(self, pp: PluginProcess) -> None: - """Terminate a plugin process.""" - if pp.process is None: - return - try: - if _IS_WINDOWS: - # On Windows, send CTRL_BREAK_EVENT to the process group - os.kill(pp.process.pid, signal.CTRL_BREAK_EVENT) - else: - pp.process.terminate() - pp.process.wait(timeout=5) - except (OSError, subprocess.TimeoutExpired): - # Graceful termination failed - force kill - try: - pp.process.kill() - pp.process.wait(timeout=2) - except (OSError, subprocess.TimeoutExpired) as e: - # Process may already be dead or unresponsive - continue cleanup - logger.debug("Force kill cleanup for %s: %s", pp.config.name, e) - pp.process = None - pp.healthy = False - - def restart_plugin(self, name: str) -> bool: - """Restart a plugin by name.""" - pp = self._plugins.get(name) - if pp is None: - return False - self._kill(pp) - pp.restart_count += 1 - return self._start_plugin(pp) - - def check_and_restart(self) -> dict[str, str]: - """Check all plugins and restart crashed ones per policy. - - Returns {name: action} where action is 'ok', 'restarted', 'dead', 'skipped'. - """ - results = {} - for name, pp in self._plugins.items(): - if not pp.config.auto_start: - results[name] = "skipped" - continue - - if pp.process is None or pp.process.poll() is not None: - # Process has exited - if pp.config.restart == "never": - results[name] = "dead" - pp.healthy = False - elif pp.config.restart == "on-failure" and pp.restart_count < self._max_restarts: - # Increment count BEFORE attempting restart (counts attempts, not successes) - pp.restart_count += 1 - logger.info( - "Restarting crashed plugin %s (attempt %d/%d)", - name, pp.restart_count, self._max_restarts, - ) - if self._start_plugin(pp): - results[name] = "restarted" - else: - results[name] = "dead" - pp.healthy = False - elif pp.config.restart == "always": - pp.restart_count += 1 - if self._start_plugin(pp): - results[name] = "restarted" - else: - results[name] = "dead" - pp.healthy = False - else: - results[name] = "dead" - pp.healthy = False - else: - results[name] = "ok" - - return results - def get_plugin(self, name: str) -> PluginProcess | None: - """Get a managed plugin by name.""" + """Get a plugin by name.""" return self._plugins.get(name) def healthy_plugins(self) -> list[PluginProcess]: @@ -427,26 +234,23 @@ def healthy_plugins(self) -> list[PluginProcess]: return [pp for pp in self._plugins.values() if pp.healthy] def stop_all(self) -> None: - """Stop all managed plugin processes.""" - for name, pp in self._plugins.items(): - if pp.process is not None: - logger.info("Stopping plugin %s", name) - self._kill(pp) + """No-op: Docker Compose manages container lifecycle.""" + logger.info("stop_all called - containers managed by Docker Compose") @property def plugins(self) -> dict[str, PluginProcess]: - """Access all managed plugins.""" + """Access all plugins.""" return self._plugins @property def is_loading(self) -> bool: - """True while plugins are starting up.""" + """True while plugins are being discovered.""" return self._loading @property def loading_status(self) -> dict[str, Any]: """Return current loading status for display.""" - total = len([pp for pp in self._plugins.values() if pp.config.auto_start]) + total = len(self._plugins) loading = list(self._loading_plugins) ready = len(self._startup_results) diff --git a/app/main.py b/app/main.py index 22bcc23..2684b48 100644 --- a/app/main.py +++ b/app/main.py @@ -29,21 +29,21 @@ async def lifespan(app: FastAPI): logger.info("Starting Game Theory Workbench...") discover_plugins() - # Start remote plugin services in background (subprocess-managed) - # This lets the server respond immediately while plugins initialize + # Discover remote plugin services in background (Docker Compose-managed) + # This lets the server respond immediately while plugins are discovered project_root = get_project_root() start_remote_plugins(project_root, background=True) - logger.info("Plugins starting in background...") + logger.info("Discovering plugins in background...") load_example_games() store = get_game_store() - logger.info("Server ready. %d games loaded. Plugins initializing...", len(store.list())) + logger.info("Server ready. %d games loaded. Discovering plugins...", len(store.list())) yield # Shutdown store's background executor store.shutdown(wait=True) - # Shutdown remote plugins + # Cleanup (no-op for Docker-managed plugins) stop_remote_plugins() @@ -145,7 +145,7 @@ def get_plugin_status() -> list[dict]: "name": name, "status": status_str, "healthy": pp.healthy, - "port": pp.port if pp.healthy else None, + "url": pp.url if pp.healthy else None, "analyses": [a.get("name") for a in pp.analyses] if pp.healthy else [], } # Include compile_targets if the plugin advertises them @@ -176,13 +176,13 @@ def check_applicable(game_id: str) -> dict: results: dict[str, dict] = {} for name, pp in plugin_manager.plugins.items(): - if not pp.healthy or not pp.port: + if not pp.healthy or not pp.url: continue # Try to call /check-applicable on the plugin try: response = httpx.post( - f"http://127.0.0.1:{pp.port}/check-applicable", + f"{pp.url}/check-applicable", json={"game": game.model_dump()}, timeout=2.0, ) diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 030ffcd..8602e9e 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -96,21 +96,21 @@ def register_healthy_plugins() -> list[str]: def start_remote_plugins(project_root: Path | None = None, background: bool = False) -> dict[str, bool]: - """Load config, start remote plugin subprocesses, register their analyses. + """Discover Docker Compose-managed plugins and register their analyses. - If background=True, starts plugins in background and returns immediately. + If background=True, discovers plugins in background and returns immediately. Call register_healthy_plugins() later to register analyses as plugins become ready. - Returns {plugin_name: started_ok} (empty dict if background=True). + Returns {plugin_name: discovered_ok} (empty dict if background=True). """ plugin_manager.load_config(project_root) if background: - def _start_and_register(): + def _discover_and_register(): plugin_manager.start_all(background=False) register_healthy_plugins() - thread = threading.Thread(target=_start_and_register, daemon=True) + thread = threading.Thread(target=_discover_and_register, daemon=True) thread.start() return {} @@ -120,5 +120,5 @@ def _start_and_register(): def stop_remote_plugins() -> None: - """Stop all managed remote plugin subprocesses.""" + """No-op: Docker Compose manages container lifecycle.""" plugin_manager.stop_all() diff --git a/app/static_mount.py b/app/static_mount.py index 62c2827..1488095 100644 --- a/app/static_mount.py +++ b/app/static_mount.py @@ -11,6 +11,10 @@ def mount_frontend(app: FastAPI) -> None: dist_dir = frontend_dir / "dist" static_dir = dist_dir if dist_dir.exists() else frontend_dir + # Only mount static files if directory exists (not in Docker API-only mode) + if not static_dir.exists(): + return + # Serve built assets app.mount("/static", StaticFiles(directory=static_dir), name="static") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a98518a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,107 @@ +# Docker Compose configuration for Game Theory Workbench +# Usage: docker-compose up + +services: + app: + build: + context: . + dockerfile: docker/Dockerfile.app + ports: + - "8000:8000" + environment: + - GAMBIT_URL=http://gambit:5001 + - PYCID_URL=http://pycid:5002 + - EGTTOOLS_URL=http://egttools:5003 + - VEGAS_URL=http://vegas:5004 + - OPENSPIEL_URL=http://openspiel:5005 + depends_on: + gambit: + condition: service_healthy + pycid: + condition: service_healthy + egttools: + condition: service_healthy + vegas: + condition: service_healthy + openspiel: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + gambit: + build: + context: . + dockerfile: docker/Dockerfile.gambit + ports: + - "5001:5001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5001/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: on-failure + + pycid: + build: + context: . + dockerfile: docker/Dockerfile.pycid + ports: + - "5002:5002" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5002/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s # PyCID takes longer to initialize + restart: on-failure + + egttools: + build: + context: . + dockerfile: docker/Dockerfile.egttools + ports: + - "5003:5003" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5003/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + restart: on-failure + + vegas: + build: + context: . + dockerfile: docker/Dockerfile.vegas + ports: + - "5004:5004" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5004/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + restart: on-failure + + openspiel: + build: + context: . + dockerfile: docker/Dockerfile.openspiel + ports: + - "5005:5005" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5005/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: on-failure + +networks: + default: + name: thrones-network diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app new file mode 100644 index 0000000..f11dd2f --- /dev/null +++ b/docker/Dockerfile.app @@ -0,0 +1,22 @@ +# Main FastAPI application +# Build with: docker build -f docker/Dockerfile.app -t thrones-app:latest . + +FROM thrones-base:latest + +WORKDIR /app + +# Install app-specific dependencies +RUN pip install --no-cache-dir \ + httpx \ + python-multipart \ + pytest + +# Copy application code +COPY app/ ./app/ +COPY examples/ ./examples/ +COPY plugins.toml ./ +COPY tests/ ./tests/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base new file mode 100644 index 0000000..24baed8 --- /dev/null +++ b/docker/Dockerfile.base @@ -0,0 +1,20 @@ +# Base image for all Thrones services +# Build with: docker build -f docker/Dockerfile.base -t thrones-base:latest . + +FROM python:3.12-slim + +WORKDIR /app + +# Install curl for healthchecks +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# Install shared Python dependencies (latest versions) +RUN pip install --no-cache-dir \ + fastapi \ + uvicorn[standard] \ + pydantic + +# Copy and install shared package +COPY shared-pkg/ /app/shared-pkg/ +RUN pip install --no-cache-dir /app/shared-pkg/ diff --git a/docker/Dockerfile.egttools b/docker/Dockerfile.egttools new file mode 100644 index 0000000..1c35df5 --- /dev/null +++ b/docker/Dockerfile.egttools @@ -0,0 +1,17 @@ +# EGTTools plugin - Evolutionary game theory analysis +# Build with: docker build -f docker/Dockerfile.egttools -t thrones-egttools:latest . + +FROM thrones-base:latest + +WORKDIR /app/plugins/egttools + +# Install EGTTools-specific dependencies +RUN pip install --no-cache-dir numpy + +# Copy plugin code +COPY plugins/egttools/egttools_plugin/ ./egttools_plugin/ + +ENV PORT=5003 +EXPOSE 5003 + +CMD ["python", "-m", "egttools_plugin", "--host", "0.0.0.0", "--port", "5003"] diff --git a/docker/Dockerfile.gambit b/docker/Dockerfile.gambit new file mode 100644 index 0000000..cd58f61 --- /dev/null +++ b/docker/Dockerfile.gambit @@ -0,0 +1,22 @@ +# Gambit plugin - Nash equilibrium, IESDS, support enumeration +# Build with: docker build -f docker/Dockerfile.gambit -t thrones-gambit:latest . + +FROM thrones-base:latest + +WORKDIR /app/plugins/gambit + +# Install build tools for pygambit compilation +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Install Gambit-specific dependencies (pinned version with pre-built wheel) +RUN pip install --no-cache-dir pygambit==16.5.0 + +# Copy plugin code +COPY plugins/gambit/gambit_plugin/ ./gambit_plugin/ + +ENV PORT=5001 +EXPOSE 5001 + +CMD ["python", "-m", "gambit_plugin", "--host", "0.0.0.0", "--port", "5001"] diff --git a/docker/Dockerfile.openspiel b/docker/Dockerfile.openspiel new file mode 100644 index 0000000..2ecc441 --- /dev/null +++ b/docker/Dockerfile.openspiel @@ -0,0 +1,25 @@ +# OpenSpiel plugin - CFR and exploitability analysis +# Build with: docker build -f docker/Dockerfile.openspiel -t thrones-openspiel:latest . + +FROM thrones-base:latest + +WORKDIR /app/plugins/openspiel + +# Install build tools (may be needed for some dependencies) +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Install OpenSpiel dependencies +# Note: open_spiel only works on Linux (which is fine in Docker) +RUN pip install --no-cache-dir \ + numpy \ + open_spiel + +# Copy plugin code +COPY plugins/openspiel/openspiel_plugin/ ./openspiel_plugin/ + +ENV PORT=5005 +EXPOSE 5005 + +CMD ["python", "-m", "openspiel_plugin", "--host", "0.0.0.0", "--port", "5005"] diff --git a/docker/Dockerfile.pycid b/docker/Dockerfile.pycid new file mode 100644 index 0000000..6897d1d --- /dev/null +++ b/docker/Dockerfile.pycid @@ -0,0 +1,29 @@ +# PyCID plugin - Multi-Agent Influence Diagram analysis +# Build with: docker build -f docker/Dockerfile.pycid -t thrones-pycid:latest . + +FROM thrones-base:latest + +WORKDIR /app/plugins/pycid + +# Install build tools for pygambit compilation +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Install PyCID-specific dependencies +# Note: Install pygambit first, then pycid with --no-deps to avoid version conflicts +RUN pip install --no-cache-dir pygambit==16.5.0 +RUN pip install --no-cache-dir \ + pgmpy==0.1.17 \ + matplotlib \ + networkx \ + numpy +RUN pip install --no-cache-dir --no-deps pycid + +# Copy plugin code +COPY plugins/pycid/pycid_plugin/ ./pycid_plugin/ + +ENV PORT=5002 +EXPOSE 5002 + +CMD ["python", "-m", "pycid_plugin", "--host", "0.0.0.0", "--port", "5002"] diff --git a/docker/Dockerfile.vegas b/docker/Dockerfile.vegas new file mode 100644 index 0000000..6dc75d6 --- /dev/null +++ b/docker/Dockerfile.vegas @@ -0,0 +1,16 @@ +# Vegas plugin - Vegas DSL parser and compiler +# Build with: docker build -f docker/Dockerfile.vegas -t thrones-vegas:latest . + +FROM thrones-base:latest + +WORKDIR /app/plugins/vegas + +# No additional dependencies beyond base image + +# Copy plugin code +COPY plugins/vegas/vegas_plugin/ ./vegas_plugin/ + +ENV PORT=5004 +EXPOSE 5004 + +CMD ["python", "-m", "vegas_plugin", "--host", "0.0.0.0", "--port", "5004"] diff --git a/plugins.toml b/plugins.toml index 856d882..af216fe 100644 --- a/plugins.toml +++ b/plugins.toml @@ -1,64 +1,31 @@ # Plugin Configuration # ==================== -# Each plugin runs as an isolated FastAPI subprocess in its own virtual environment. -# This avoids dependency conflicts between plugins (e.g., different pygambit versions). +# Plugins run as Docker containers managed by Docker Compose. +# URLs are configured via environment variables (e.g., GAMBIT_URL). # -# On app startup, PluginManager launches each plugin on a dynamic port. -# Plugins expose: GET /health, GET /info, POST /analyze, POST /parse/{format} +# Plugin endpoints: GET /health, GET /info, POST /analyze, POST /parse/{format} [settings] -# Maximum seconds to wait for a plugin to become healthy after starting +# Maximum seconds to wait for a plugin container to become healthy # PyCID plugin takes ~30s to import libraries on first load startup_timeout_seconds = 60 -# Maximum restart attempts before marking a plugin as permanently dead -max_restarts = 3 # Plugin Definitions # ------------------ -# Each [[plugins]] entry defines one plugin subprocess: -# name - Unique identifier for the plugin -# command - Command to launch the plugin (receives --port=N argument) -# cwd - Working directory relative to project root -# auto_start - Whether to start plugin on app startup (default: true) -# restart - Restart policy: "never" | "on-failure" | "always" (default: "on-failure") +# Each [[plugins]] entry defines one plugin service. +# URLs come from environment variables: {NAME}_URL (e.g., GAMBIT_URL) [[plugins]] name = "gambit" -command = ["plugins/gambit/.venv/Scripts/python", "-m", "gambit_plugin"] -cwd = "plugins/gambit" -auto_start = true -restart = "on-failure" [[plugins]] name = "pycid" -command = ["plugins/pycid/.venv/Scripts/python", "-m", "pycid_plugin"] -cwd = "plugins/pycid" -auto_start = true -restart = "on-failure" [[plugins]] name = "vegas" -command = ["plugins/vegas/.venv/Scripts/python", "-m", "vegas_plugin"] -cwd = "plugins/vegas" -auto_start = true -restart = "on-failure" [[plugins]] name = "egttools" -command = ["plugins/egttools/.venv/Scripts/python", "-m", "egttools_plugin"] -cwd = "plugins/egttools" -auto_start = true -restart = "on-failure" [[plugins]] name = "openspiel" -# OpenSpiel only works on Linux/macOS. On Windows, WSL support is experimental. -# The NTFS→WSL filesystem bridge causes Python imports to hang indefinitely. -# For Windows users who want OpenSpiel: clone the plugin to WSL's native filesystem. -# Setup: scripts/setup-plugins.sh creates .venv (native venv for Linux/macOS) -command = ["plugins/openspiel/.venv/bin/python", "-m", "openspiel_plugin"] -cwd = "plugins/openspiel" -auto_start = true -# Skip on Windows due to WSL+NTFS performance issues (imports hang) -skip_on_windows = true -restart = "on-failure" diff --git a/scripts/create_venv.bat b/scripts/create_venv.bat deleted file mode 100644 index aa1a941..0000000 --- a/scripts/create_venv.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off -REM Create a .venv using Python 3.12 -py -3.12 -m venv .venv -if %ERRORLEVEL% neq 0 ( - echo Failed to create virtual environment. - exit /b %ERRORLEVEL% -) - -echo Created .venv using Python 3.12. -echo To activate (cmd): .\.venv\Scripts\activate.bat -echo To activate (PowerShell): .\.venv\Scripts\Activate.ps1 diff --git a/scripts/create_venv.ps1 b/scripts/create_venv.ps1 deleted file mode 100644 index 33e18b3..0000000 --- a/scripts/create_venv.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -param( - [string]$PythonLauncher = 'py -3.12' -) - -if (-not (Get-Command py -ErrorAction SilentlyContinue)) { - Write-Error "Python launcher 'py' not found. Install Python 3.12 or use the full python executable path." - exit 1 -} - -& py -3.12 -m venv .venv - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create virtual environment." - exit $LASTEXITCODE -} - -Write-Output "Created .venv using Python 3.12." -Write-Output "Activate in PowerShell: .\\.venv\\Scripts\\Activate.ps1" -Write-Output "Activate in cmd: .\\.venv\\Scripts\\activate.bat" diff --git a/scripts/restart.ps1 b/scripts/restart.ps1 deleted file mode 100644 index 1d0c6e8..0000000 --- a/scripts/restart.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# Restart the Game Theory Workbench backend server - -Write-Host "Stopping existing Python/uvicorn processes..." -ForegroundColor Yellow - -# Find and stop uvicorn processes -Get-Process -Name "python" -ErrorAction SilentlyContinue | Where-Object { - $_.CommandLine -like "*uvicorn*app.main*" -} | Stop-Process -Force -ErrorAction SilentlyContinue - -Start-Sleep -Seconds 1 - -Write-Host "Starting uvicorn server..." -ForegroundColor Green - -# Change to project root -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$projectRoot = Split-Path -Parent $scriptDir -Set-Location $projectRoot - -# Start the server using venv -$venvPython = Join-Path $projectRoot ".venv\Scripts\python.exe" -Start-Process $venvPython -ArgumentList "-m", "uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000" -NoNewWindow - -Write-Host "Server starting on http://localhost:8000" -ForegroundColor Cyan diff --git a/scripts/restart.sh b/scripts/restart.sh deleted file mode 100644 index f87a6b5..0000000 --- a/scripts/restart.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# Restart the Game Theory Workbench backend server - -set -e - -echo "Stopping existing uvicorn processes..." -pkill -f "uvicorn app.main" 2>/dev/null || true - -sleep 1 - -echo "Starting uvicorn server..." -cd "$(dirname "$0")/.." -.venv/bin/python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 & - -echo "Server starting on http://localhost:8000" diff --git a/scripts/run-all-tests.ps1 b/scripts/run-all-tests.ps1 deleted file mode 100644 index 68b4187..0000000 --- a/scripts/run-all-tests.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -# Run all tests: main app, plugins, and integration -# Run from the project root: .\scripts\run-all-tests.ps1 - -$ErrorActionPreference = "Continue" -$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -Set-Location $projectRoot - -$failed = 0 - -# --- Main App Tests (no plugins needed) --- -Write-Host "`n=== Main App Tests ===" -ForegroundColor Cyan -& .venv\Scripts\python -m pytest tests/ -v --tb=short --ignore=tests/integration -if ($LASTEXITCODE -ne 0) { $failed++ } - -# --- Gambit Plugin Tests --- -if (Test-Path "plugins\gambit\venv\Scripts\python.exe") { - Write-Host "`n=== Gambit Plugin Tests ===" -ForegroundColor Cyan - & plugins\gambit\venv\Scripts\python -m pytest plugins\gambit\tests\ -v --tb=short - if ($LASTEXITCODE -ne 0) { $failed++ } -} else { - Write-Host "`n=== Gambit Plugin Tests SKIPPED (no venv) ===" -ForegroundColor DarkYellow -} - -# --- PyCID Plugin Tests --- -if (Test-Path "plugins\pycid\venv\Scripts\python.exe") { - Write-Host "`n=== PyCID Plugin Tests ===" -ForegroundColor Cyan - & plugins\pycid\venv\Scripts\python -m pytest plugins\pycid\tests\ -v --tb=short - if ($LASTEXITCODE -ne 0) { $failed++ } -} else { - Write-Host "`n=== PyCID Plugin Tests SKIPPED (no venv) ===" -ForegroundColor DarkYellow -} - -# --- Integration Tests (plugins started automatically by app lifespan) --- -Write-Host "`n=== Integration Tests ===" -ForegroundColor Cyan -& .venv\Scripts\python -m pytest tests\integration\ -v --tb=short -if ($LASTEXITCODE -ne 0) { $failed++ } - -# --- Summary --- -Write-Host "`n========================================" -ForegroundColor Cyan -if ($failed -eq 0) { - Write-Host "All test suites passed." -ForegroundColor Green -} else { - Write-Host "$failed test suite(s) had failures." -ForegroundColor Red -} -exit $failed diff --git a/scripts/setup-plugins.ps1 b/scripts/setup-plugins.ps1 deleted file mode 100644 index d688d92..0000000 --- a/scripts/setup-plugins.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -# Setup script for isolated plugin virtual environments (Windows) -# Run from the project root: .\scripts\setup-plugins.ps1 - -$ErrorActionPreference = "Stop" - -$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -Set-Location $projectRoot - -Write-Host "Setting up plugin virtual environments..." -ForegroundColor Cyan - -# --- Gambit plugin --- -$gambitDir = "plugins\gambit" -if (Test-Path $gambitDir) { - Write-Host "`n--- Gambit Plugin ---" -ForegroundColor Yellow - if (-not (Test-Path "$gambitDir\.venv")) { - Write-Host "Creating .venv..." - python -m .venv "$gambitDir\.venv" - } - Write-Host "Installing dependencies..." - & "$gambitDir\.venv\Scripts\pip" install -e "$gambitDir[dev]" --quiet - Write-Host "Gambit plugin ready." -ForegroundColor Green -} else { - Write-Host "Gambit plugin directory not found, skipping." -ForegroundColor DarkYellow -} - -# --- PyCID plugin --- -$pycidDir = "plugins\pycid" -if (Test-Path $pycidDir) { - Write-Host "`n--- PyCID Plugin ---" -ForegroundColor Yellow - if (-not (Test-Path "$pycidDir\.venv")) { - Write-Host "Creating .venv..." - python -m .venv "$pycidDir\.venv" - } - Write-Host "Installing dependencies..." - & "$pycidDir\.venv\Scripts\pip" install -e "$pycidDir[dev]" --quiet - Write-Host "PyCID plugin ready." -ForegroundColor Green -} else { - Write-Host "PyCID plugin directory not found, skipping." -ForegroundColor DarkYellow -} - -# --- Vegas plugin --- -$vegasDir = "plugins\vegas" -if (Test-Path $vegasDir) { - Write-Host "`n--- Vegas Plugin ---" -ForegroundColor Yellow - if (-not (Test-Path "$vegasDir\.venv")) { - Write-Host "Creating .venv..." - python -m venv "$vegasDir\.venv" - } - Write-Host "Installing dependencies..." - & "$vegasDir\.venv\Scripts\pip" install -e "$vegasDir[dev]" --quiet - - # Check that Vegas JAR exists - $vegasJar = "$vegasDir\lib\vegas.jar" - if (-not (Test-Path $vegasJar)) { - Write-Host "Warning: Vegas JAR not found at $vegasJar" -ForegroundColor DarkYellow - Write-Host "Build Vegas with 'cd ../vegas && mvn package -DskipTests' and copy the JAR" -ForegroundColor DarkYellow - } - Write-Host "Vegas plugin ready." -ForegroundColor Green -} else { - Write-Host "Vegas plugin directory not found, skipping." -ForegroundColor DarkYellow -} - -# --- EGTTools plugin --- -$egttoolsDir = "plugins\egttools" -if (Test-Path $egttoolsDir) { - Write-Host "`n--- EGTTools Plugin ---" -ForegroundColor Yellow - if (-not (Test-Path "$egttoolsDir\.venv")) { - Write-Host "Creating .venv..." - python -m venv "$egttoolsDir\.venv" - } - Write-Host "Installing dependencies..." - & "$egttoolsDir\.venv\Scripts\pip" install -e "$egttoolsDir[dev]" --quiet - Write-Host "EGTTools plugin ready." -ForegroundColor Green -} else { - Write-Host "EGTTools plugin directory not found, skipping." -ForegroundColor DarkYellow -} - -# --- OpenSpiel plugin --- -# NOTE: OpenSpiel is disabled on Windows by default (skip_on_windows=true in plugins.toml) -# due to WSL2+NTFS performance issues causing Python imports to hang. -# See plugins/openspiel/README.md for manual setup on native WSL filesystem. -Write-Host "`n--- OpenSpiel Plugin ---" -ForegroundColor Yellow -Write-Host "OpenSpiel is disabled on Windows (WSL+NTFS causes hangs)." -ForegroundColor DarkYellow -Write-Host "See plugins/openspiel/README.md for manual WSL setup." -ForegroundColor DarkYellow - -Write-Host "`nPlugin setup complete." -ForegroundColor Cyan diff --git a/scripts/setup-plugins.sh b/scripts/setup-plugins.sh deleted file mode 100644 index fa51faf..0000000 --- a/scripts/setup-plugins.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# Setup script for isolated plugin virtual environments (Unix) -# Run from the project root: bash scripts/setup-plugins.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -cd "$PROJECT_ROOT" - -echo "Setting up plugin virtual environments..." - -# --- Gambit plugin --- -GAMBIT_DIR="plugins/gambit" -if [ -d "$GAMBIT_DIR" ]; then - echo "" - echo "--- Gambit Plugin ---" - if [ ! -d "$GAMBIT_DIR/.venv" ]; then - echo "Creating venv..." - python3 -m venv "$GAMBIT_DIR/.venv" - fi - echo "Installing dependencies..." - "$GAMBIT_DIR/.venv/bin/pip" install -e "$GAMBIT_DIR[dev]" --quiet - echo "Gambit plugin ready." -else - echo "Gambit plugin directory not found, skipping." -fi - -# --- PyCID plugin --- -PYCID_DIR="plugins/pycid" -if [ -d "$PYCID_DIR" ]; then - echo "" - echo "--- PyCID Plugin ---" - if [ ! -d "$PYCID_DIR/.venv" ]; then - echo "Creating venv..." - python3 -m venv "$PYCID_DIR/.venv" - fi - echo "Installing dependencies..." - "$PYCID_DIR/.venv/bin/pip" install -e "$PYCID_DIR[dev]" --quiet - echo "PyCID plugin ready." -else - echo "PyCID plugin directory not found, skipping." -fi - -# --- EGTTools plugin --- -EGTTOOLS_DIR="plugins/egttools" -if [ -d "$EGTTOOLS_DIR" ]; then - echo "" - echo "--- EGTTools Plugin ---" - if [ ! -d "$EGTTOOLS_DIR/.venv" ]; then - echo "Creating venv..." - python3 -m venv "$EGTTOOLS_DIR/.venv" - fi - echo "Installing dependencies..." - "$EGTTOOLS_DIR/.venv/bin/pip" install -e "$EGTTOOLS_DIR[dev]" --quiet - echo "EGTTools plugin ready." -else - echo "EGTTools plugin directory not found, skipping." -fi - -# --- OpenSpiel plugin --- -OPENSPIEL_DIR="plugins/openspiel" -if [ -d "$OPENSPIEL_DIR" ]; then - echo "" - echo "--- OpenSpiel Plugin ---" - if [ ! -d "$OPENSPIEL_DIR/.venv" ]; then - echo "Creating venv..." - python3 -m venv "$OPENSPIEL_DIR/.venv" - fi - echo "Installing dependencies (including open_spiel)..." - "$OPENSPIEL_DIR/.venv/bin/pip" install -e "$OPENSPIEL_DIR[dev,openspiel]" --quiet - echo "OpenSpiel plugin ready." -else - echo "OpenSpiel plugin directory not found, skipping." -fi - -echo "" -echo "Plugin setup complete." diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 1de2f9a..189c4a6 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -1,8 +1,9 @@ -"""Tests for the plugin manager (subprocess supervisor).""" +"""Tests for the plugin manager (Docker container discovery).""" from __future__ import annotations import textwrap from pathlib import Path +from unittest.mock import patch import pytest @@ -10,29 +11,18 @@ PluginConfig, PluginManager, PluginProcess, - _find_free_port, load_plugins_toml, ) -class TestFindFreePort: - def test_returns_port_number(self): - port = _find_free_port() - assert isinstance(port, int) - assert 1024 <= port <= 65535 - - def test_different_ports(self): - ports = {_find_free_port() for _ in range(5)} - # Should get at least 2 different ports in 5 attempts - assert len(ports) >= 2 - - class TestPluginConfig: def test_defaults(self): - config = PluginConfig(name="test", command=["python", "-m", "test"]) - assert config.auto_start is True - assert config.restart == "on-failure" - assert config.cwd == "." + config = PluginConfig(name="test") + assert config.url == "" + + def test_with_url(self): + config = PluginConfig(name="test", url="http://localhost:5000") + assert config.url == "http://localhost:5000" class TestLoadPluginsToml: @@ -45,37 +35,25 @@ def test_load_valid_toml(self, tmp_path): toml_content = textwrap.dedent("""\ [settings] startup_timeout_seconds = 5 - max_restarts = 2 [[plugins]] name = "test-plugin" - command = ["python", "-m", "test_plugin"] - cwd = "plugins/test" - auto_start = true - restart = "on-failure" """) toml_file = tmp_path / "plugins.toml" toml_file.write_text(toml_content, encoding="utf-8") settings, plugins = load_plugins_toml(toml_file) assert settings["startup_timeout_seconds"] == 5 - assert settings["max_restarts"] == 2 assert len(plugins) == 1 assert plugins[0].name == "test-plugin" - assert plugins[0].command == ["python", "-m", "test_plugin"] - assert plugins[0].cwd == "plugins/test" - assert plugins[0].auto_start is True def test_load_multiple_plugins(self, tmp_path): toml_content = textwrap.dedent("""\ [[plugins]] name = "plugin-a" - command = ["python", "-m", "a"] [[plugins]] name = "plugin-b" - command = ["python", "-m", "b"] - auto_start = false """) toml_file = tmp_path / "plugins.toml" toml_file.write_text(toml_content, encoding="utf-8") @@ -84,7 +62,29 @@ def test_load_multiple_plugins(self, tmp_path): assert len(plugins) == 2 assert plugins[0].name == "plugin-a" assert plugins[1].name == "plugin-b" - assert plugins[1].auto_start is False + + def test_url_from_environment(self, tmp_path, monkeypatch): + """Plugin URLs should come from environment variables.""" + toml_content = textwrap.dedent("""\ + [[plugins]] + name = "gambit" + """) + toml_file = tmp_path / "plugins.toml" + toml_file.write_text(toml_content, encoding="utf-8") + + # Set environment variable + monkeypatch.setenv("GAMBIT_URL", "http://custom-host:9999") + + # Reload the module to pick up the new env var + import importlib + import app.config + importlib.reload(app.config) + import app.core.plugin_manager + importlib.reload(app.core.plugin_manager) + from app.core.plugin_manager import load_plugins_toml as reload_load + + _, plugins = reload_load(toml_file) + assert plugins[0].url == "http://custom-host:9999" class TestPluginManager: @@ -95,7 +95,6 @@ def test_load_config(self, tmp_path): [[plugins]] name = "test" - command = ["python", "-m", "test"] """) toml_file = tmp_path / "plugins.toml" toml_file.write_text(toml_content, encoding="utf-8") @@ -118,7 +117,6 @@ def test_get_plugin(self, tmp_path): toml_content = textwrap.dedent("""\ [[plugins]] name = "my-plugin" - command = ["python", "-m", "my_plugin"] """) toml_file = tmp_path / "plugins.toml" toml_file.write_text(toml_content, encoding="utf-8") @@ -129,19 +127,29 @@ def test_get_plugin(self, tmp_path): assert manager.get_plugin("my-plugin") is not None assert manager.get_plugin("nonexistent") is None - def test_stop_all_no_processes(self): - """stop_all should handle case where no processes are running.""" + def test_stop_all_noop(self): + """stop_all should be a no-op (Docker manages containers).""" manager = PluginManager() manager.stop_all() # Should not raise + def test_loading_status_initial(self): + manager = PluginManager() + status = manager.loading_status + assert status["loading"] is False + assert status["total_plugins"] == 0 + assert status["plugins_ready"] == 0 + class TestPluginProcess: def test_default_state(self): - config = PluginConfig(name="test", command=["python"]) + config = PluginConfig(name="test") pp = PluginProcess(config=config) - assert pp.port == 0 - assert pp.process is None assert pp.url == "" assert pp.healthy is False - assert pp.restart_count == 0 assert pp.analyses == [] + assert pp.info == {} + + def test_with_url(self): + config = PluginConfig(name="test", url="http://localhost:5000") + pp = PluginProcess(config=config, url="http://localhost:5000") + assert pp.url == "http://localhost:5000" From 41f2d7497a24822ca150fb5f2b83b59ca4ee13d7 Mon Sep 17 00:00:00 2001 From: Elazar Gershuni Date: Sun, 1 Feb 2026 01:00:16 +0200 Subject: [PATCH 2/5] Consistent disabling --- app/core/remote_plugin.py | 11 +- app/main.py | 11 +- .../src/components/panels/AnalysisPanel.tsx | 12 +- .../panels/CFRConvergenceSection.tsx | 3 - .../panels/EvolutionaryStabilitySection.tsx | 3 - .../panels/ExploitabilitySection.tsx | 3 - .../panels/ReplicatorDynamicsSection.tsx | 3 - .../openspiel/openspiel_plugin/__main__.py | 231 +++++++++--------- plugins/openspiel/openspiel_plugin/cfr.py | 39 +-- .../openspiel_plugin/exploitability.py | 28 +++ shared-pkg/shared/__init__.py | 3 + shared-pkg/shared/efg_export.py | 111 +++++++++ 12 files changed, 311 insertions(+), 147 deletions(-) create mode 100644 shared-pkg/shared/efg_export.py diff --git a/app/core/remote_plugin.py b/app/core/remote_plugin.py index 48183b1..f99be9b 100644 --- a/app/core/remote_plugin.py +++ b/app/core/remote_plugin.py @@ -7,6 +7,7 @@ from app.config import RemotePluginConfig from app.core.http_client import RemoteServiceClient, RemoteServiceError from app.core.registry import AnalysisResult +from shared import export_to_efg logger = logging.getLogger(__name__) @@ -39,13 +40,21 @@ def run(self, game, config: dict | None = None) -> AnalysisResult: # Strip internal keys that don't serialize clean_config = {k: v for k, v in config.items() if not k.startswith("_")} + # Serialize game, adding efg_content for extensive-form games + game_data = game.model_dump() + if getattr(game, "format_name", None) == "extensive": + try: + game_data["efg_content"] = export_to_efg(game_data) + except Exception as e: + logger.warning("Failed to generate EFG content: %s", e) + # Submit analysis try: task = self._client.post( "/analyze", json={ "analysis": self.name, - "game": game.model_dump(), + "game": game_data, "config": clean_config, }, timeout=RemotePluginConfig.SUBMIT_TIMEOUT_SECONDS, diff --git a/app/main.py b/app/main.py index 2684b48..2706795 100644 --- a/app/main.py +++ b/app/main.py @@ -175,6 +175,15 @@ def check_applicable(game_id: str) -> dict: results: dict[str, dict] = {} + # Prepare game data with EFG content for extensive-form games + game_data = game.model_dump() + if getattr(game, "format_name", None) == "extensive": + from shared import export_to_efg + try: + game_data["efg_content"] = export_to_efg(game_data) + except Exception: + pass # Continue without EFG content + for name, pp in plugin_manager.plugins.items(): if not pp.healthy or not pp.url: continue @@ -183,7 +192,7 @@ def check_applicable(game_id: str) -> dict: try: response = httpx.post( f"{pp.url}/check-applicable", - json={"game": game.model_dump()}, + json={"game": game_data}, timeout=2.0, ) if response.status_code == 200: diff --git a/frontend/src/components/panels/AnalysisPanel.tsx b/frontend/src/components/panels/AnalysisPanel.tsx index 7fa904f..dc6726d 100644 --- a/frontend/src/components/panels/AnalysisPanel.tsx +++ b/frontend/src/components/panels/AnalysisPanel.tsx @@ -41,10 +41,6 @@ export function AnalysisPanel({ onSelectCompiledTab }: AnalysisPanelProps) { const isNfgCapable = nativeFormat === 'normal' || canConvertToNormal; const isMaidCapable = nativeFormat === 'maid'; - // Check plugin health for OpenSpiel (may not be available on Windows) - const plugins = usePluginStore((state) => state.plugins); - const openspielHealthy = plugins?.find((p) => p.name === 'openspiel')?.healthy ?? false; - // Fetch analysis applicability when game changes const fetchApplicability = usePluginStore((state) => state.fetchApplicability); // Subscribe to the actual state to trigger re-renders when applicability changes @@ -311,8 +307,8 @@ export function AnalysisPanel({ onSelectCompiledTab }: AnalysisPanelProps) { result={exploitabilityResult} isLoading={loadingAnalysis === 'exploitability'} isExpanded={expandedSections.has('exploitability')} - disabled={!openspielHealthy} - disabledReason="OpenSpiel unavailable (requires Linux/macOS)" + disabled={!getAnalysisApplicability('Exploitability').applicable} + disabledReason={getAnalysisApplicability('Exploitability').reason} onToggle={() => toggleSection('exploitability')} onRun={handleRunExploitability} onCancel={cancelAnalysis} @@ -322,8 +318,8 @@ export function AnalysisPanel({ onSelectCompiledTab }: AnalysisPanelProps) { result={cfrConvergenceResult} isLoading={loadingAnalysis === 'cfr-convergence'} isExpanded={expandedSections.has('cfr-convergence')} - disabled={!openspielHealthy} - disabledReason="OpenSpiel unavailable (requires Linux/macOS)" + disabled={!getAnalysisApplicability('CFR Convergence').applicable} + disabledReason={getAnalysisApplicability('CFR Convergence').reason} onToggle={() => toggleSection('cfr-convergence')} onRun={handleRunCFRConvergence} onCancel={cancelAnalysis} diff --git a/frontend/src/components/panels/CFRConvergenceSection.tsx b/frontend/src/components/panels/CFRConvergenceSection.tsx index feaf4dc..c80d697 100644 --- a/frontend/src/components/panels/CFRConvergenceSection.tsx +++ b/frontend/src/components/panels/CFRConvergenceSection.tsx @@ -65,9 +65,6 @@ export function CFRConvergenceSection({ CFR Convergence
- {disabled && ( - Unavailable - )} {result?.details.computation_time_ms !== undefined && ( {result.details.computation_time_ms as number}ms )} diff --git a/frontend/src/components/panels/EvolutionaryStabilitySection.tsx b/frontend/src/components/panels/EvolutionaryStabilitySection.tsx index c5f069a..f73b4c3 100644 --- a/frontend/src/components/panels/EvolutionaryStabilitySection.tsx +++ b/frontend/src/components/panels/EvolutionaryStabilitySection.tsx @@ -54,9 +54,6 @@ export function EvolutionaryStabilitySection({ Evolutionary Stability
- {disabled && disabledReason && ( - {disabledReason} - )} {result?.details.computation_time_ms !== undefined && ( {result.details.computation_time_ms as number}ms )} diff --git a/frontend/src/components/panels/ExploitabilitySection.tsx b/frontend/src/components/panels/ExploitabilitySection.tsx index e1c0258..57b995b 100644 --- a/frontend/src/components/panels/ExploitabilitySection.tsx +++ b/frontend/src/components/panels/ExploitabilitySection.tsx @@ -62,9 +62,6 @@ export function ExploitabilitySection({ Exploitability
- {disabled && ( - Unavailable - )} {result?.details.computation_time_ms !== undefined && ( {result.details.computation_time_ms as number}ms )} diff --git a/frontend/src/components/panels/ReplicatorDynamicsSection.tsx b/frontend/src/components/panels/ReplicatorDynamicsSection.tsx index 0cb90e9..9895f1f 100644 --- a/frontend/src/components/panels/ReplicatorDynamicsSection.tsx +++ b/frontend/src/components/panels/ReplicatorDynamicsSection.tsx @@ -91,9 +91,6 @@ export function ReplicatorDynamicsSection({ Replicator Dynamics
- {disabled && disabledReason && ( - {disabledReason} - )} {result?.details.computation_time_ms !== undefined && ( {result.details.computation_time_ms as number}ms )} diff --git a/plugins/openspiel/openspiel_plugin/__main__.py b/plugins/openspiel/openspiel_plugin/__main__.py index 0a8a712..773fe25 100644 --- a/plugins/openspiel/openspiel_plugin/__main__.py +++ b/plugins/openspiel/openspiel_plugin/__main__.py @@ -2,15 +2,11 @@ Run with: python -m openspiel_plugin --port=PORT Implements the plugin HTTP contract (API v1). - -NOTE: OpenSpiel only works on Linux/macOS. On Windows, this plugin -will start but return an error status explaining that WSL is required. """ from __future__ import annotations import argparse import logging -import sys import threading import uuid from enum import Enum @@ -30,91 +26,82 @@ PLUGIN_VERSION = "0.1.0" API_VERSION = 1 -# Check platform compatibility -IS_WINDOWS = sys.platform == "win32" -PLATFORM_ERROR = ( - "OpenSpiel is not available on Windows. " - "Please use WSL (Windows Subsystem for Linux) to run this plugin. " - "See plugins/openspiel/README.md for setup instructions." -) - # --------------------------------------------------------------------------- -# Analysis registry (only load if not Windows) +# Analysis registry # --------------------------------------------------------------------------- -if not IS_WINDOWS: - from openspiel_plugin.cfr import run_cfr_equilibrium, run_best_response - from openspiel_plugin.exploitability import ( - run_exploitability, - run_policy_exploitability, - ) - - ANALYSES = { - "CFR Equilibrium": { - "name": "CFR Equilibrium", - "description": "Compute approximate Nash equilibrium using Counterfactual Regret Minimization.", - "applicable_to": ["extensive"], - "continuous": False, - "config_schema": { - "iterations": { - "type": "integer", - "default": 1000, - "description": "Number of CFR iterations", - }, - "algorithm": { - "type": "string", - "enum": ["cfr", "cfr+", "mccfr"], - "default": "cfr+", - "description": "CFR algorithm variant", - }, +from openspiel_plugin.cfr import run_cfr_equilibrium, run_best_response +from openspiel_plugin.exploitability import ( + run_exploitability, + run_policy_exploitability, + check_zero_sum, +) + +ANALYSES = { + "CFR Equilibrium": { + "name": "CFR Equilibrium", + "description": "Compute approximate Nash equilibrium using Counterfactual Regret Minimization.", + "applicable_to": ["extensive"], + "continuous": False, + "config_schema": { + "iterations": { + "type": "integer", + "default": 1000, + "description": "Number of CFR iterations", + }, + "algorithm": { + "type": "string", + "enum": ["cfr", "cfr+", "mccfr"], + "default": "cfr+", + "description": "CFR algorithm variant", }, - "run": run_cfr_equilibrium, - }, - "Exploitability": { - "name": "Exploitability", - "description": "Measure distance from Nash equilibrium (nash_conv).", - "applicable_to": ["extensive"], - "continuous": False, - "config_schema": {}, - "run": run_exploitability, }, - "CFR Convergence": { - "name": "CFR Convergence", - "description": "Run CFR and track exploitability over iterations.", - "applicable_to": ["extensive"], - "continuous": False, - "config_schema": { - "iterations": { - "type": "integer", - "default": 1000, - "description": "Number of CFR iterations", - }, - "algorithm": { - "type": "string", - "enum": ["cfr", "cfr+"], - "default": "cfr+", - "description": "CFR algorithm variant", - }, + "run": run_cfr_equilibrium, + }, + "Exploitability": { + "name": "Exploitability", + "description": "Measure distance from Nash equilibrium (nash_conv). Requires zero-sum games.", + "applicable_to": ["extensive"], + "continuous": False, + "config_schema": {}, + "run": run_exploitability, + "check_applicable": check_zero_sum, + }, + "CFR Convergence": { + "name": "CFR Convergence", + "description": "Run CFR and track exploitability over iterations.", + "applicable_to": ["extensive"], + "continuous": False, + "config_schema": { + "iterations": { + "type": "integer", + "default": 1000, + "description": "Number of CFR iterations", + }, + "algorithm": { + "type": "string", + "enum": ["cfr", "cfr+"], + "default": "cfr+", + "description": "CFR algorithm variant", }, - "run": run_policy_exploitability, }, - "Best Response": { - "name": "Best Response", - "description": "Compute optimal counter-strategy to a policy.", - "applicable_to": ["extensive"], - "continuous": False, - "config_schema": { - "player": { - "type": "integer", - "default": 0, - "description": "Player index to compute best response for", - }, + "run": run_policy_exploitability, + }, + "Best Response": { + "name": "Best Response", + "description": "Compute optimal counter-strategy to a policy.", + "applicable_to": ["extensive"], + "continuous": False, + "config_schema": { + "player": { + "type": "integer", + "default": 0, + "description": "Player index to compute best response for", }, - "run": run_best_response, }, - } -else: - ANALYSES = {} + "run": run_best_response, + }, +} # --------------------------------------------------------------------------- # Task state @@ -159,6 +146,10 @@ class AnalyzeRequest(BaseModel): config: dict[str, Any] = {} +class CheckApplicableRequest(BaseModel): + game: dict[str, Any] + + # --------------------------------------------------------------------------- # FastAPI app # --------------------------------------------------------------------------- @@ -168,13 +159,6 @@ class AnalyzeRequest(BaseModel): @app.get("/health") def health() -> dict: - if IS_WINDOWS: - return { - "status": "error", - "api_version": API_VERSION, - "plugin_version": PLUGIN_VERSION, - "error": PLATFORM_ERROR, - } return { "status": "ok", "api_version": API_VERSION, @@ -184,15 +168,6 @@ def health() -> dict: @app.get("/info") def info() -> dict: - if IS_WINDOWS: - return { - "api_version": API_VERSION, - "plugin_version": PLUGIN_VERSION, - "analyses": [], - "conversions": [], - "error": PLATFORM_ERROR, - } - analyses_info = [] for a in ANALYSES.values(): analyses_info.append({ @@ -210,19 +185,42 @@ def info() -> dict: } -@app.post("/analyze") -def analyze(req: AnalyzeRequest) -> dict: - if IS_WINDOWS: - raise HTTPException( - status_code=503, - detail={ - "error": { - "code": "PLATFORM_NOT_SUPPORTED", - "message": PLATFORM_ERROR, +@app.post("/check-applicable") +def check_applicable(req: CheckApplicableRequest) -> dict: + """Check which analyses are applicable to the given game. + + Returns applicability status and reason for each analysis. + """ + results = {} + game_format = req.game.get("format_name", "") + + for name, analysis in ANALYSES.items(): + # First check format compatibility + if game_format not in analysis["applicable_to"]: + results[name] = { + "applicable": False, + "reason": f"Requires {' or '.join(analysis['applicable_to']).upper()} format", + } + continue + + # Then check analysis-specific constraints + check_fn = analysis.get("check_applicable") + if check_fn: + check_result = check_fn(req.game) + if not check_result.get("applicable", True): + results[name] = { + "applicable": False, + "reason": check_result.get("reason", "Not applicable"), } - }, - ) + continue + + results[name] = {"applicable": True} + + return {"analyses": results} + +@app.post("/analyze") +def analyze(req: AnalyzeRequest) -> dict: analysis_entry = ANALYSES.get(req.analysis) if analysis_entry is None: raise HTTPException( @@ -247,6 +245,21 @@ def analyze(req: AnalyzeRequest) -> dict: }, ) + # Check analysis-specific constraints + check_fn = analysis_entry.get("check_applicable") + if check_fn: + check_result = check_fn(req.game) + if not check_result.get("applicable", True): + raise HTTPException( + status_code=400, + detail={ + "error": { + "code": "INVALID_GAME", + "message": check_result.get("reason", "Game not compatible"), + } + }, + ) + task_id = f"os-{uuid.uuid4().hex[:8]}" task = TaskState() @@ -312,10 +325,6 @@ def main() -> None: parser.add_argument("--host", default="127.0.0.1", help="Host to bind to") args = parser.parse_args() - if IS_WINDOWS: - logger.warning("OpenSpiel plugin running on Windows - functionality limited") - logger.warning(PLATFORM_ERROR) - logger.info("Starting OpenSpiel plugin on %s:%d", args.host, args.port) uvicorn.run(app, host=args.host, port=args.port, log_level="info") diff --git a/plugins/openspiel/openspiel_plugin/cfr.py b/plugins/openspiel/openspiel_plugin/cfr.py index f5c3e97..062a055 100644 --- a/plugins/openspiel/openspiel_plugin/cfr.py +++ b/plugins/openspiel/openspiel_plugin/cfr.py @@ -76,28 +76,39 @@ def run_cfr_equilibrium( # Extract average policy (the converged strategy) average_policy = solver.average_policy() - # Convert policy to serializable format + # Convert policy to serializable format by traversing the game tree strategy = {} - for state in spiel_game.new_initial_state().get_all_successors(): - if state.is_chance_node() or state.is_terminal(): - continue + + def collect_strategy(state): + """Recursively collect strategy from all reachable states.""" + if state.is_terminal(): + return + if state.is_chance_node(): + for action in state.legal_actions(): + collect_strategy(state.child(action)) + return player = state.current_player() info_state = state.information_state_string(player) legal_actions = state.legal_actions() - action_probs = {} + if info_state not in strategy: + action_probs = average_policy.action_probabilities(state) + action_names = {} + for action in legal_actions: + prob = action_probs.get(action, 0) + action_name = spiel_game.action_to_string(player, action) + action_names[action_name] = float(prob) + strategy[info_state] = action_names + + # Recurse to children for action in legal_actions: - prob = average_policy.action_probabilities(state).get(action, 0) - action_name = spiel_game.action_to_string(player, action) - action_probs[action_name] = float(prob) + collect_strategy(state.child(action)) - if info_state not in strategy: - strategy[info_state] = action_probs + collect_strategy(spiel_game.new_initial_state()) - # Get player names - players = game.get("players", []) - player_names = [p.get("name", f"Player {i+1}") for i, p in enumerate(players)] + # Get player names from game data + player_list = game.get("players", []) return { "summary": f"CFR equilibrium ({algorithm}, {iterations} iterations)", @@ -105,7 +116,7 @@ def run_cfr_equilibrium( "strategy": strategy, "algorithm": algorithm, "iterations": iterations, - "players": player_names, + "players": player_list, }, } diff --git a/plugins/openspiel/openspiel_plugin/exploitability.py b/plugins/openspiel/openspiel_plugin/exploitability.py index 7a4fe9f..f2581af 100644 --- a/plugins/openspiel/openspiel_plugin/exploitability.py +++ b/plugins/openspiel/openspiel_plugin/exploitability.py @@ -4,6 +4,34 @@ from typing import Any +def check_zero_sum(game: dict[str, Any]) -> dict[str, Any]: + """Check if a game is zero-sum or constant-sum (required for exploitability). + + Args: + game: Game dict with efg_content. + + Returns: + Dict with 'applicable' (bool) and optional 'reason' (str). + """ + import pyspiel + + efg_content = game.get("efg_content") + if not efg_content: + return {"applicable": False, "reason": "Requires EFG content"} + + try: + spiel_game = pyspiel.load_efg_game(efg_content) + utility = spiel_game.get_type().utility + + if utility in (pyspiel.GameType.Utility.ZERO_SUM, + pyspiel.GameType.Utility.CONSTANT_SUM): + return {"applicable": True} + else: + return {"applicable": False, "reason": "Requires zero-sum game"} + except Exception: + return {"applicable": False, "reason": "Failed to parse game"} + + def run_exploitability( game: dict[str, Any], config: dict[str, Any] | None = None ) -> dict[str, Any]: diff --git a/shared-pkg/shared/__init__.py b/shared-pkg/shared/__init__.py index 02a8b12..8f279a5 100644 --- a/shared-pkg/shared/__init__.py +++ b/shared-pkg/shared/__init__.py @@ -7,3 +7,6 @@ The utilities operate on plain dicts to maximize compatibility. The core app provides wrapper functions that convert Pydantic models to dicts. """ +from shared.efg_export import export_to_efg + +__all__ = ["export_to_efg"] diff --git a/shared-pkg/shared/efg_export.py b/shared-pkg/shared/efg_export.py new file mode 100644 index 0000000..0668ece --- /dev/null +++ b/shared-pkg/shared/efg_export.py @@ -0,0 +1,111 @@ +"""Export extensive-form game dicts to Gambit EFG format.""" +from __future__ import annotations + +from typing import Any + + +def export_to_efg(game: dict[str, Any]) -> str: + """Convert an extensive-form game dict to Gambit EFG text format. + + EFG format reference: https://gambitproject.readthedocs.io/en/latest/formats.html + + Args: + game: Dict with keys: players, title, root, nodes, outcomes. + nodes[id] = {id, player, actions: [{label, target}], information_set?} + outcomes[id] = {label, payoffs: {player: value}} + + Returns: + EFG format string that can be parsed by Gambit/OpenSpiel. + """ + lines = [] + + players = game.get("players", []) + nodes = game.get("nodes", {}) + outcomes = game.get("outcomes", {}) + root = game.get("root", "") + title = game.get("title", "Game").replace('"', "'") + + # Header: EFG 2 R "Title" { "Player1" "Player2" ... } + player_list = " ".join(f'"{p}"' for p in players) + lines.append(f'EFG 2 R "{title}" {{ {player_list} }}') + lines.append("") + + # Build player index (1-based for Gambit) + player_idx = {name: i + 1 for i, name in enumerate(players)} + + # Track information sets per player + infoset_counter: dict[int, int] = {i + 1: 0 for i in range(len(players))} + infoset_map: dict[str, int] = {} + + # Track outcome numbers (1-based) + outcome_counter = [0] # Use list for mutability in nested function + outcome_number_map: dict[str, int] = {} + + def get_infoset_number(player: int, infoset_name: str | None) -> int: + """Get or create information set number for a player.""" + if infoset_name is None: + # Singleton information set - create unique one + infoset_counter[player] += 1 + return infoset_counter[player] + + key = f"{player}:{infoset_name}" + if key not in infoset_map: + infoset_counter[player] += 1 + infoset_map[key] = infoset_counter[player] + return infoset_map[key] + + def get_outcome_number(outcome_id: str) -> int: + """Get or create outcome number for a terminal node.""" + if outcome_id not in outcome_number_map: + outcome_counter[0] += 1 + outcome_number_map[outcome_id] = outcome_counter[0] + return outcome_number_map[outcome_id] + + def traverse(node_id: str) -> list[str]: + """Recursively traverse and generate EFG lines.""" + result = [] + + # Check if this is an outcome (terminal) + if node_id in outcomes: + outcome = outcomes[node_id] + # Terminal node: t "label" outcome_number { payoffs } + payoff_dict = outcome.get("payoffs", {}) + payoffs = ", ".join(str(payoff_dict.get(p, 0)) for p in players) + label = outcome.get("label", node_id).replace('"', "'") + outcome_num = get_outcome_number(node_id) + result.append(f't "{label}" {outcome_num} "{label}" {{ {payoffs} }}') + return result + + # Decision node + node = nodes.get(node_id) + if node is None: + # Missing node - create dummy terminal + outcome_num = get_outcome_number(f"missing_{node_id}") + result.append(f't "" {outcome_num} "missing_{node_id}" {{ {", ".join("0" for _ in players)} }}') + return result + + player_name = node.get("player", "") + player = player_idx.get(player_name, 1) + infoset = get_infoset_number(player, node.get("information_set")) + actions = node.get("actions", []) + action_labels = " ".join(f'"{a.get("label", "?").replace(chr(34), chr(39))}"' for a in actions) + + # Personal node: p "label" player infoset { actions } 0 + label = node.get("id", node_id).replace('"', "'") + result.append(f'p "{label}" {player} {infoset} {{ {action_labels} }} 0') + + # Recursively add children + for action in actions: + target = action.get("target") + if target: + result.extend(traverse(target)) + else: + # No target - create dummy terminal + outcome_num = get_outcome_number(f"none_{node_id}_{action.get('label', '')}") + result.append(f't "" {outcome_num} "none" {{ {", ".join("0" for _ in players)} }}') + + return result + + lines.extend(traverse(root)) + + return "\n".join(lines) From c3099189812c087996b412fd2fafce9d7ce35702 Mon Sep 17 00:00:00 2001 From: Elazar Gershuni Date: Sun, 1 Feb 2026 01:46:04 +0200 Subject: [PATCH 3/5] black --- app/config.py | 2 +- app/conversions/__init__.py | 2 + app/conversions/efg_nfg.py | 22 ++- app/conversions/efg_string.py | 172 ++++++++++++++++++ app/conversions/registry.py | 18 +- app/conversions/remote.py | 1 + app/core/analysis_helpers.py | 1 + app/core/errors.py | 1 + app/core/http_client.py | 1 + app/core/paths.py | 2 +- app/core/plugin_manager.py | 24 ++- app/core/registry.py | 12 +- app/core/remote_plugin.py | 81 ++++++++- app/core/store.py | 20 +- app/core/strategies.py | 5 +- app/core/tasks.py | 27 ++- app/dependencies.py | 5 + app/formats/__init__.py | 1 + app/formats/json_format.py | 7 +- app/formats/remote.py | 1 + app/main.py | 157 +++++++++++----- app/models/__init__.py | 5 +- app/models/efg_string.py | 24 +++ app/models/extensive_form.py | 8 +- app/models/maid.py | 1 + app/models/normal_form.py | 1 + app/models/vegas.py | 1 + app/plugins/__init__.py | 18 +- app/plugins/dominance.py | 19 +- app/plugins/validation.py | 16 +- app/routes/analyses.py | 30 +-- app/routes/games.py | 14 +- app/routes/tasks.py | 5 +- app/static_mount.py | 1 + docker/Dockerfile.vegas | 8 +- plugins/egttools/egttools_plugin/__main__.py | 17 +- plugins/egttools/egttools_plugin/fixation.py | 1 + .../egttools/egttools_plugin/replicator.py | 5 +- plugins/egttools/tests/test_replicator.py | 22 +-- plugins/gambit/gambit_plugin/__main__.py | 37 +++- plugins/gambit/gambit_plugin/gambit_utils.py | 9 +- plugins/gambit/gambit_plugin/iesds.py | 17 +- plugins/gambit/gambit_plugin/levelk.py | 14 +- plugins/gambit/gambit_plugin/nash.py | 89 +++++++-- plugins/gambit/gambit_plugin/parsers.py | 19 +- plugins/gambit/gambit_plugin/qre.py | 15 +- plugins/gambit/gambit_plugin/strategies.py | 1 + plugins/gambit/gambit_plugin/supports.py | 11 +- .../gambit/gambit_plugin/verify_profile.py | 5 +- plugins/gambit/tests/test_analyses.py | 8 +- plugins/gambit/tests/test_iesds.py | 16 +- plugins/gambit/tests/test_nash.py | 7 +- plugins/gambit/tests/test_parsers.py | 24 +-- plugins/gambit/tests/test_verify_profile.py | 10 +- .../openspiel/openspiel_plugin/__main__.py | 30 +-- plugins/pycid/pycid_plugin/__main__.py | 22 ++- plugins/pycid/pycid_plugin/convert.py | 29 ++- plugins/pycid/pycid_plugin/nash.py | 5 +- plugins/pycid/pycid_plugin/pycid_utils.py | 18 +- plugins/pycid/pycid_plugin/spe.py | 5 +- plugins/pycid/pycid_plugin/verify_profile.py | 34 ++-- plugins/pycid/tests/test_convert.py | 23 ++- plugins/pycid/tests/test_nash.py | 7 +- plugins/pycid/tests/test_spe.py | 7 +- plugins/vegas/tests/test_parser.py | 22 ++- plugins/vegas/vegas_plugin/__main__.py | 8 +- plugins/vegas/vegas_plugin/parser.py | 25 ++- tests/test_remote_plugin.py | 19 +- 68 files changed, 981 insertions(+), 313 deletions(-) create mode 100644 app/conversions/efg_string.py create mode 100644 app/models/efg_string.py diff --git a/app/config.py b/app/config.py index 7494e5c..3ef4b57 100644 --- a/app/config.py +++ b/app/config.py @@ -3,11 +3,11 @@ Consolidates magic numbers and configuration values that were previously scattered throughout the codebase. """ + from __future__ import annotations import os - # Plugin URLs from environment variables (for Docker Compose) PLUGIN_URLS: dict[str, str] = { "gambit": os.environ.get("GAMBIT_URL", "http://gambit:5001"), diff --git a/app/conversions/__init__.py b/app/conversions/__init__.py index 3e6ecad..df314de 100644 --- a/app/conversions/__init__.py +++ b/app/conversions/__init__.py @@ -2,6 +2,7 @@ Provides conversions between game representations (e.g., EFG <-> NFG). """ + from app.conversions.registry import ( Conversion, ConversionCheck, @@ -10,6 +11,7 @@ # Import converters for registration side effects from app.conversions import efg_nfg as _efg_nfg # noqa: F401 +from app.conversions import efg_string as _efg_string # noqa: F401 __all__ = [ "Conversion", diff --git a/app/conversions/efg_nfg.py b/app/conversions/efg_nfg.py index ed54f65..8b16ab3 100644 --- a/app/conversions/efg_nfg.py +++ b/app/conversions/efg_nfg.py @@ -1,15 +1,19 @@ """Conversions between extensive form and normal form games.""" + from __future__ import annotations from typing import Mapping from app.config import ConversionConfig from app.conversions.registry import Conversion, ConversionCheck -from app.core.strategies import enumerate_strategies, estimate_strategy_count, resolve_payoffs +from app.core.strategies import ( + enumerate_strategies, + estimate_strategy_count, + resolve_payoffs, +) from app.dependencies import get_conversion_registry from app.models import NormalFormGame, ExtensiveFormGame, Action, DecisionNode, Outcome - # ============================================================================= # EFG -> NFG Conversion # ============================================================================= @@ -23,7 +27,9 @@ def check_efg_to_nfg(game: ExtensiveFormGame | NormalFormGame) -> ConversionChec if len(game.players) != 2: return ConversionCheck( possible=False, - blockers=[f"Matrix view requires exactly 2 players (game has {len(game.players)})"], + blockers=[ + f"Matrix view requires exactly 2 players (game has {len(game.players)})" + ], ) # Estimate strategy count WITHOUT enumerating (could be exponential!) @@ -34,7 +40,9 @@ def check_efg_to_nfg(game: ExtensiveFormGame | NormalFormGame) -> ConversionChec # Block conversion if too large (would hang or exhaust memory) if num_profiles > ConversionConfig.STRATEGY_COUNT_BLOCKING_THRESHOLD: - blockers.append(f"Too many strategy profiles ({num_profiles:,}) - conversion would be impractical") + blockers.append( + f"Too many strategy profiles ({num_profiles:,}) - conversion would be impractical" + ) return ConversionCheck(possible=False, warnings=warnings, blockers=blockers) if num_profiles > ConversionConfig.STRATEGY_COUNT_WARNING_THRESHOLD: @@ -180,7 +188,11 @@ def convert_nfg_to_efg(game: ExtensiveFormGame | NormalFormGame) -> ExtensiveFor root=root_id, nodes=nodes, outcomes=outcomes, - tags=[*[t for t in game.tags if t != "strategic-form"], "converted", "from-nfg"], + tags=[ + *[t for t in game.tags if t != "strategic-form"], + "converted", + "from-nfg", + ], ) diff --git a/app/conversions/efg_string.py b/app/conversions/efg_string.py new file mode 100644 index 0000000..ebe09d2 --- /dev/null +++ b/app/conversions/efg_string.py @@ -0,0 +1,172 @@ +"""Conversion from ExtensiveFormGame to Gambit EFG text format. + +EFG is the standard format used by Gambit, OpenSpiel, and other game theory tools. +""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from app.conversions.registry import Conversion, ConversionCheck +from app.models.efg_string import EfgStringGame + +if TYPE_CHECKING: + from app.models.extensive_form import ExtensiveFormGame + + +def _export_to_efg(game: dict[str, Any]) -> str: + """Convert an ExtensiveFormGame dict to Gambit EFG text format. + + EFG format reference: https://gambitproject.readthedocs.io/en/latest/formats.html + + Args: + game: Dict with keys: players, title, root, nodes, outcomes. + nodes[id] = {id, player, actions: [{label, target}], information_set?} + outcomes[id] = {label, payoffs: {player: value}} + + Returns: + EFG format string that can be parsed by Gambit/OpenSpiel. + """ + lines = [] + + players = game.get("players", []) + nodes = game.get("nodes", {}) + outcomes = game.get("outcomes", {}) + root = game.get("root", "") + title = game.get("title", "Game").replace('"', "'") + + # Header: EFG 2 R "Title" { "Player1" "Player2" ... } + player_list = " ".join(f'"{p}"' for p in players) + lines.append(f'EFG 2 R "{title}" {{ {player_list} }}') + lines.append("") + + # Build player index (1-based for Gambit) + player_idx = {name: i + 1 for i, name in enumerate(players)} + + # Track information sets per player + infoset_counter: dict[int, int] = {i + 1: 0 for i in range(len(players))} + infoset_map: dict[str, int] = {} + + # Track outcome numbers (1-based) + outcome_counter = [0] # Use list for mutability in nested function + outcome_number_map: dict[str, int] = {} + + def get_infoset_number(player: int, infoset_name: str | None) -> int: + """Get or create information set number for a player.""" + if infoset_name is None: + # Singleton information set - create unique one + infoset_counter[player] += 1 + return infoset_counter[player] + + key = f"{player}:{infoset_name}" + if key not in infoset_map: + infoset_counter[player] += 1 + infoset_map[key] = infoset_counter[player] + return infoset_map[key] + + def get_outcome_number(outcome_id: str) -> int: + """Get or create outcome number for a terminal node.""" + if outcome_id not in outcome_number_map: + outcome_counter[0] += 1 + outcome_number_map[outcome_id] = outcome_counter[0] + return outcome_number_map[outcome_id] + + def traverse(node_id: str) -> list[str]: + """Recursively traverse and generate EFG lines.""" + result = [] + + # Check if this is an outcome (terminal) + if node_id in outcomes: + outcome = outcomes[node_id] + # Terminal node: t "label" outcome_number { payoffs } + payoff_dict = outcome.get("payoffs", {}) + payoffs = ", ".join(str(payoff_dict.get(p, 0)) for p in players) + label = outcome.get("label", node_id).replace('"', "'") + outcome_num = get_outcome_number(node_id) + result.append(f't "{label}" {outcome_num} "{label}" {{ {payoffs} }}') + return result + + # Decision node + node = nodes.get(node_id) + if node is None: + # Missing node - create dummy terminal + outcome_num = get_outcome_number(f"missing_{node_id}") + result.append( + f't "" {outcome_num} "missing_{node_id}" {{ {", ".join("0" for _ in players)} }}' + ) + return result + + player_name = node.get("player", "") + player = player_idx.get(player_name, 1) + infoset = get_infoset_number(player, node.get("information_set")) + actions = node.get("actions", []) + action_labels = " ".join( + f'"{a.get("label", "?").replace(chr(34), chr(39))}"' for a in actions + ) + + # Personal node: p "label" player infoset { actions } 0 + label = node.get("id", node_id).replace('"', "'") + result.append(f'p "{label}" {player} {infoset} {{ {action_labels} }} 0') + + # Recursively add children + for action in actions: + target = action.get("target") + if target: + result.extend(traverse(target)) + else: + # No target - create dummy terminal + outcome_num = get_outcome_number( + f"none_{node_id}_{action.get('label', '')}" + ) + result.append( + f't "" {outcome_num} "none" {{ {", ".join("0" for _ in players)} }}' + ) + + return result + + lines.extend(traverse(root)) + + return "\n".join(lines) + + +def _can_convert_to_efg(game: "ExtensiveFormGame") -> ConversionCheck: + """Check if an extensive-form game can be converted to EFG.""" + # All extensive-form games can be converted to EFG + return ConversionCheck(possible=True) + + +def _convert_extensive_to_efg(game: "ExtensiveFormGame") -> EfgStringGame: + """Convert ExtensiveFormGame to EfgStringGame (Gambit EFG format).""" + game_dict = game.model_dump() + efg_content = _export_to_efg(game_dict) + return EfgStringGame( + id=game.id, + title=game.title, + players=list(game.players), + efg_content=efg_content, + tags=list(game.tags), + ) + + +# ============================================================================= +# Registration +# ============================================================================= + + +def _register_conversions() -> None: + """Register extensive -> EFG conversion.""" + from app.dependencies import get_conversion_registry + + registry = get_conversion_registry() + registry.register( + Conversion( + name="extensive to efg", + source_format="extensive", + target_format="efg", + can_convert=_can_convert_to_efg, + convert=_convert_extensive_to_efg, + ) + ) + + +_register_conversions() diff --git a/app/conversions/registry.py b/app/conversions/registry.py index b5c98cc..af82734 100644 --- a/app/conversions/registry.py +++ b/app/conversions/registry.py @@ -3,6 +3,7 @@ Provides a simple, extensible registry for converting between game representations (e.g., extensive form <-> normal form). """ + from __future__ import annotations from dataclasses import dataclass, field @@ -59,7 +60,10 @@ def _find_conversion_path( for (src, intermediate1), _ in self._conversions.items(): if src == source_format: if (intermediate1, target_format) in self._conversions: - return [(source_format, intermediate1), (intermediate1, target_format)] + return [ + (source_format, intermediate1), + (intermediate1, target_format), + ] # Try 3-hop path: source -> int1 -> int2 -> target # Needed for vegas -> maid -> extensive -> normal @@ -100,7 +104,9 @@ def check( if not path: return ConversionCheck( possible=False, - blockers=[f"No conversion path from {source_format} to {target_format}"], + blockers=[ + f"No conversion path from {source_format} to {target_format}" + ], ) # Quick check: only verify path exists and first step is possible @@ -169,7 +175,9 @@ def convert(self, game: "AnyGame", target_format: str) -> "AnyGame": check_result = conversion.can_convert(current_game) if not check_result.possible: - msg = f"Cannot convert {src} to {tgt}: {', '.join(check_result.blockers)}" + msg = ( + f"Cannot convert {src} to {tgt}: {', '.join(check_result.blockers)}" + ) raise ValueError(msg) current_game = conversion.convert(current_game) @@ -204,7 +212,9 @@ def available_conversions( continue check_result = self.check(game, target, quick=quick) # Only include if possible or has a path (even if blocked) - if check_result.possible or self._find_conversion_path(source_format, target): + if check_result.possible or self._find_conversion_path( + source_format, target + ): results[target] = check_result return results diff --git a/app/conversions/remote.py b/app/conversions/remote.py index f6cd94f..f0fcc6d 100644 --- a/app/conversions/remote.py +++ b/app/conversions/remote.py @@ -3,6 +3,7 @@ Proxies conversion requests to remote plugin services that support game format conversions (e.g., MAID to EFG via the pycid plugin). """ + from __future__ import annotations import logging diff --git a/app/core/analysis_helpers.py b/app/core/analysis_helpers.py index 0e9adf9..e6d1cb9 100644 --- a/app/core/analysis_helpers.py +++ b/app/core/analysis_helpers.py @@ -1,4 +1,5 @@ """Shared helpers for analysis operations.""" + from __future__ import annotations import logging diff --git a/app/core/errors.py b/app/core/errors.py index a778c22..48a2d43 100644 --- a/app/core/errors.py +++ b/app/core/errors.py @@ -2,6 +2,7 @@ Provides consistent error response formatting across all endpoints. """ + from __future__ import annotations from fastapi import HTTPException diff --git a/app/core/http_client.py b/app/core/http_client.py index f2ed74a..3cc4df5 100644 --- a/app/core/http_client.py +++ b/app/core/http_client.py @@ -5,6 +5,7 @@ - Remote format parsing (formats/remote.py) - Remote conversions (conversions/remote.py) """ + from __future__ import annotations import logging diff --git a/app/core/paths.py b/app/core/paths.py index 1a9c945..e469396 100644 --- a/app/core/paths.py +++ b/app/core/paths.py @@ -2,12 +2,12 @@ Uses sentinel file lookup instead of fragile relative path navigation. """ + from __future__ import annotations from functools import lru_cache from pathlib import Path - # Sentinel files that indicate project root (in order of preference) SENTINEL_FILES = ("pyproject.toml", "plugins.toml", ".git") diff --git a/app/core/plugin_manager.py b/app/core/plugin_manager.py index 68e5def..7c3d9f7 100644 --- a/app/core/plugin_manager.py +++ b/app/core/plugin_manager.py @@ -3,6 +3,7 @@ Discovers plugins by health-checking predefined URLs (from environment variables). Plugins are managed by Docker Compose, not as subprocesses. """ + from __future__ import annotations import logging @@ -121,6 +122,7 @@ def _do_discover(): if background: import threading + thread = threading.Thread(target=_do_discover, daemon=True) thread.start() return {} # Results not available yet @@ -150,19 +152,25 @@ def _discover_plugin(self, pp: PluginProcess) -> bool: self._fetch_info(pp) logger.info( "Plugin %s healthy at %s (%d analyses)", - pp.config.name, pp.url, len(pp.analyses), + pp.config.name, + pp.url, + len(pp.analyses), ) return True if health_result == "degraded": pp.healthy = False - logger.info("Plugin %s started in degraded mode at %s", pp.config.name, pp.url) + logger.info( + "Plugin %s started in degraded mode at %s", pp.config.name, pp.url + ) return True # Still counts as "discovered" logger.warning("Plugin %s not reachable at %s", pp.config.name, pp.url) return False - def _wait_for_health(self, pp: PluginProcess, timeout: float | None = None) -> bool | str: + def _wait_for_health( + self, pp: PluginProcess, timeout: float | None = None + ) -> bool | str: """Poll /health with exponential backoff. Returns: @@ -188,17 +196,21 @@ def _wait_for_health(self, pp: PluginProcess, timeout: float | None = None) -> b error_msg = data.get("error", "Unknown error") logger.warning( "Plugin %s started but degraded: %s", - pp.config.name, error_msg, + pp.config.name, + error_msg, ) pp.info = {"error": error_msg, "status": "error"} return "degraded" logger.warning( "Plugin %s health response unexpected: %s", - pp.config.name, data, + pp.config.name, + data, ) except (httpx.ConnectError, httpx.TimeoutException): # Expected during startup - container not ready yet - logger.debug("Plugin %s not ready yet (connection/timeout)", pp.config.name) + logger.debug( + "Plugin %s not ready yet (connection/timeout)", pp.config.name + ) except httpx.HTTPStatusError as e: logger.debug("Health check HTTP error for %s: %s", pp.config.name, e) diff --git a/app/core/registry.py b/app/core/registry.py index 71878b5..c353e27 100644 --- a/app/core/registry.py +++ b/app/core/registry.py @@ -4,6 +4,7 @@ of a plugin-driven system. Plugins register themselves by calling ``registry.register_plugin`` at import time. """ + from __future__ import annotations from typing import Any, Protocol, Iterable, runtime_checkable @@ -33,14 +34,13 @@ class AnalysisPlugin(Protocol): applicable_to: tuple[str, ...] continuous: bool - def can_run(self, game: ExtensiveFormGame) -> bool: - ... + def can_run(self, game: ExtensiveFormGame) -> bool: ... - def run(self, game: ExtensiveFormGame, config: dict | None = None) -> AnalysisResult: - ... + def run( + self, game: ExtensiveFormGame, config: dict | None = None + ) -> AnalysisResult: ... - def summarize(self, result: AnalysisResult) -> str: - ... + def summarize(self, result: AnalysisResult) -> str: ... class Registry: diff --git a/app/core/remote_plugin.py b/app/core/remote_plugin.py index f99be9b..f754020 100644 --- a/app/core/remote_plugin.py +++ b/app/core/remote_plugin.py @@ -1,4 +1,5 @@ """HTTP adapter that makes a remote plugin service look like a local AnalysisPlugin.""" + from __future__ import annotations import logging @@ -23,14 +24,77 @@ def __init__(self, base_url: str, analysis_info: dict): self.base_url = base_url self.name: str = analysis_info["name"] self.description: str = analysis_info.get("description", "") - self.applicable_to: tuple[str, ...] = tuple(analysis_info.get("applicable_to", ())) + self.applicable_to: tuple[str, ...] = tuple( + analysis_info.get("applicable_to", ()) + ) self.continuous: bool = analysis_info.get("continuous", True) self._config_schema: dict = analysis_info.get("config_schema", {}) self._client = RemoteServiceClient(base_url, service_name=self.name) def can_run(self, game) -> bool: - """Check if this plugin can analyze the given game.""" - return getattr(game, "format_name", None) in self.applicable_to + """Check if this plugin can analyze the given game (native or via conversion).""" + native_format = getattr(game, "format_name", None) + if native_format is None: + return False + + if native_format in self.applicable_to: + return True + + # Check if game can be converted to a supported format + from app.dependencies import get_conversion_registry + + conversion_registry = get_conversion_registry() + for target_format in self.applicable_to: + check = conversion_registry.check(game, target_format, quick=True) + if check.possible: + return True + return False + + def _prepare_game_data(self, game) -> tuple[dict | None, AnalysisResult | None]: + """Convert game to required format. Returns (game_data, error_result).""" + from app.dependencies import get_conversion_registry + + native_format = getattr(game, "format_name", None) + conversion_registry = get_conversion_registry() + + # Find a format we can provide + for target_format in self.applicable_to: + if native_format == target_format: + game_data = game.model_dump() + else: + check = conversion_registry.check(game, target_format, quick=True) + if not check.possible: + continue + try: + converted = conversion_registry.convert(game, target_format) + game_data = converted.model_dump() + except ValueError as e: + continue # Try next format + + # Add EFG content for extensive-form games + if target_format == "extensive": + try: + game_data["efg_content"] = export_to_efg(game_data) + except ValueError as e: + return None, AnalysisResult( + summary=f"Error: EFG export failed: {e}", + details={ + "error": {"code": "EFG_EXPORT_FAILED", "message": str(e)} + }, + ) + + return game_data, None + + # No format worked + return None, AnalysisResult( + summary=f"Error: Cannot convert to required format ({', '.join(self.applicable_to)})", + details={ + "error": { + "code": "NO_CONVERSION", + "message": f"Game cannot be converted to {self.applicable_to}", + } + }, + ) def run(self, game, config: dict | None = None) -> AnalysisResult: """Submit analysis to remote plugin and poll for result.""" @@ -40,13 +104,10 @@ def run(self, game, config: dict | None = None) -> AnalysisResult: # Strip internal keys that don't serialize clean_config = {k: v for k, v in config.items() if not k.startswith("_")} - # Serialize game, adding efg_content for extensive-form games - game_data = game.model_dump() - if getattr(game, "format_name", None) == "extensive": - try: - game_data["efg_content"] = export_to_efg(game_data) - except Exception as e: - logger.warning("Failed to generate EFG content: %s", e) + # Convert game to required format + game_data, error = self._prepare_game_data(game) + if error: + return error # Submit analysis try: diff --git a/app/core/store.py b/app/core/store.py index af93a2f..e6a1b38 100644 --- a/app/core/store.py +++ b/app/core/store.py @@ -1,4 +1,5 @@ """In-memory game store for loaded games.""" + from __future__ import annotations import logging @@ -18,7 +19,7 @@ def is_supported_format(format_name: str) -> bool: """Check if the format name is supported.""" - return format_name in ("extensive", "normal", "maid", "vegas") + return format_name in ("extensive", "normal", "maid", "vegas", "efg") class ConversionInfo(BaseModel): @@ -53,7 +54,9 @@ class GameStore: def __init__(self, precompute_conversions: bool = True) -> None: self._games: dict[str, AnyGame] = {} - self._conversions: dict[tuple[str, str], AnyGame] = {} # (game_id, format) -> converted game + self._conversions: dict[tuple[str, str], AnyGame] = ( + {} + ) # (game_id, format) -> converted game self._lock = Lock() self._precompute = precompute_conversions self._executor: ThreadPoolExecutor | None = None @@ -64,12 +67,15 @@ def _get_executor(self) -> ThreadPoolExecutor: with self._executor_lock: if self._executor is None or getattr(self._executor, "_shutdown", False): # Use a small pool - conversions are CPU-bound - self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="conversion") + self._executor = ThreadPoolExecutor( + max_workers=2, thread_name_prefix="conversion" + ) return self._executor def _get_conversion_registry(self) -> "ConversionRegistry": """Get the conversion registry (lazy import to avoid circular deps).""" from app.dependencies import get_conversion_registry + return get_conversion_registry() def add(self, game: AnyGame) -> str: @@ -238,7 +244,9 @@ def get_converted(self, game_id: str, target_format: str) -> AnyGame | None: if not check.possible: logger.debug( "Conversion not possible: %s -> %s, blockers: %s", - game.format_name, target_format, check.blockers, + game.format_name, + target_format, + check.blockers, ) return None @@ -247,7 +255,9 @@ def get_converted(self, game_id: str, target_format: str) -> AnyGame | None: except Exception as e: logger.error( "Conversion failed: %s -> %s: %s", - game.format_name, target_format, e, + game.format_name, + target_format, + e, ) return None diff --git a/app/core/strategies.py b/app/core/strategies.py index d220bb2..8a028db 100644 --- a/app/core/strategies.py +++ b/app/core/strategies.py @@ -5,6 +5,7 @@ For the underlying algorithms, see shared-pkg/shared/strategies.py. """ + from __future__ import annotations from typing import Iterator, Mapping, TYPE_CHECKING @@ -40,7 +41,9 @@ def iter_strategies( yield from shared_strategies.iter_strategies(game_dict, player) -def enumerate_strategies(game: "ExtensiveFormGame") -> dict[str, list[Mapping[str, str]]]: +def enumerate_strategies( + game: "ExtensiveFormGame", +) -> dict[str, list[Mapping[str, str]]]: """Enumerate all pure strategies for each player. A strategy is a complete plan: one action for each information set. diff --git a/app/core/tasks.py b/app/core/tasks.py index c806279..ae49e2f 100644 --- a/app/core/tasks.py +++ b/app/core/tasks.py @@ -1,4 +1,5 @@ """Task management for long-running computations.""" + from __future__ import annotations import logging @@ -138,7 +139,10 @@ def _run_task(self, task: Task, run_fn: Callable[[dict | None], Any]) -> None: return # Give plugin access to cancellation - config_with_cancel = {**(task.config or {}), "_cancel_event": task.cancel_event} + config_with_cancel = { + **(task.config or {}), + "_cancel_event": task.cancel_event, + } result = run_fn(config_with_cancel) @@ -148,7 +152,9 @@ def _run_task(self, task: Task, run_fn: Callable[[dict | None], Any]) -> None: with self._lock: task.completed_at = completed_at task.result = result - task.status = TaskStatus.CANCELLED if cancelled else TaskStatus.COMPLETED + task.status = ( + TaskStatus.CANCELLED if cancelled else TaskStatus.COMPLETED + ) if cancelled: logger.info("Task %s cancelled during execution", task.id) @@ -172,7 +178,11 @@ def cancel(self, task_id: str) -> bool: if task is None: return False - if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED): + if task.status in ( + TaskStatus.COMPLETED, + TaskStatus.FAILED, + TaskStatus.CANCELLED, + ): return False task.cancel_event.set() @@ -199,8 +209,15 @@ def cleanup(self, max_age_seconds: int = 3600) -> int: with self._lock: for task_id, task in list(self._tasks.items()): - if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED): - if task.completed_at and (now - task.completed_at) > max_age_seconds: + if task.status in ( + TaskStatus.COMPLETED, + TaskStatus.FAILED, + TaskStatus.CANCELLED, + ): + if ( + task.completed_at + and (now - task.completed_at) > max_age_seconds + ): removed_ids.append(task_id) for task_id in removed_ids: diff --git a/app/dependencies.py b/app/dependencies.py index 89e1469..b124ac9 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -24,6 +24,7 @@ def test_list_games(): # ... test ... app.dependency_overrides.clear() """ + from __future__ import annotations from functools import lru_cache @@ -40,6 +41,7 @@ def test_list_games(): def get_game_store() -> "GameStore": """Get the game store singleton.""" from app.core.store import GameStore + return GameStore() @@ -47,6 +49,7 @@ def get_game_store() -> "GameStore": def get_task_manager() -> "TaskManager": """Get the task manager singleton.""" from app.core.tasks import TaskManager + return TaskManager() @@ -54,6 +57,7 @@ def get_task_manager() -> "TaskManager": def get_registry() -> "Registry": """Get the analysis plugin registry singleton.""" from app.core.registry import Registry + return Registry() @@ -61,6 +65,7 @@ def get_registry() -> "Registry": def get_conversion_registry() -> "ConversionRegistry": """Get the conversion registry singleton.""" from app.conversions.registry import ConversionRegistry + return ConversionRegistry() diff --git a/app/formats/__init__.py b/app/formats/__init__.py index aaa5f9b..cd874c8 100644 --- a/app/formats/__init__.py +++ b/app/formats/__init__.py @@ -9,6 +9,7 @@ Remote plugin formats (.efg, .nfg, .vg) are parsed by their respective plugin services and registered dynamically on app startup when healthy. """ + from __future__ import annotations from pathlib import Path diff --git a/app/formats/json_format.py b/app/formats/json_format.py index 20a7f9b..f32da09 100644 --- a/app/formats/json_format.py +++ b/app/formats/json_format.py @@ -7,6 +7,7 @@ - Common fields: id, title, description, players, tags - Format-specific data in exactly one of: game_efg, game_nfg, game_maid """ + from __future__ import annotations import json @@ -81,7 +82,11 @@ def _is_maid_format(data: dict) -> bool: nodes = data.get("nodes", []) if nodes and isinstance(nodes, list): first_node = nodes[0] if nodes else {} - if isinstance(first_node, dict) and first_node.get("type") in ("decision", "utility", "chance"): + if isinstance(first_node, dict) and first_node.get("type") in ( + "decision", + "utility", + "chance", + ): return True return False diff --git a/app/formats/remote.py b/app/formats/remote.py index 77504ef..788b97d 100644 --- a/app/formats/remote.py +++ b/app/formats/remote.py @@ -3,6 +3,7 @@ Proxies format parsing requests to remote plugin services that support specific file formats (e.g., .efg, .nfg via the gambit plugin). """ + from __future__ import annotations import logging diff --git a/app/main.py b/app/main.py index 2706795..f6a0cac 100644 --- a/app/main.py +++ b/app/main.py @@ -3,13 +3,25 @@ import logging from contextlib import asynccontextmanager -from fastapi import FastAPI +import httpx + +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from app.bootstrap import load_example_games from app.core.paths import get_project_root from app.dependencies import get_game_store from app.static_mount import mount_frontend +from app.dependencies import get_conversion_registry +from app.plugins import ( + discover_plugins, + start_remote_plugins, + stop_remote_plugins, + plugin_manager, + register_healthy_plugins, +) + +from shared import export_to_efg # Configure logging logging.basicConfig( @@ -19,9 +31,6 @@ ) logger = logging.getLogger(__name__) -# Import plugins for registration side effects -from app.plugins import discover_plugins, start_remote_plugins, stop_remote_plugins - @asynccontextmanager async def lifespan(app: FastAPI): @@ -37,7 +46,9 @@ async def lifespan(app: FastAPI): load_example_games() store = get_game_store() - logger.info("Server ready. %d games loaded. Discovering plugins...", len(store.list())) + logger.info( + "Server ready. %d games loaded. Discovering plugins...", len(store.list()) + ) yield # Shutdown store's background executor @@ -75,7 +86,6 @@ async def lifespan(app: FastAPI): @app.get("/api/health") def health_check() -> dict: """Health check endpoint with plugin loading status.""" - from app.plugins import plugin_manager, register_healthy_plugins # Register any newly-ready plugins register_healthy_plugins() @@ -115,15 +125,15 @@ def reset_state() -> dict: count = len(store.list()) store.clear() load_example_games() - logger.info("Reset state. Cleared %d games, restored %d examples.", count, len(store.list())) + logger.info( + "Reset state. Cleared %d games, restored %d examples.", count, len(store.list()) + ) return {"status": "reset", "games_cleared": count} @app.get("/api/plugins/status") def get_plugin_status() -> list[dict]: """Return status of all managed plugins.""" - from app.plugins import plugin_manager, register_healthy_plugins - # Register any newly-ready plugins register_healthy_plugins() @@ -162,56 +172,107 @@ def get_plugin_status() -> list[dict]: def check_applicable(game_id: str) -> dict: """Check which analyses are applicable to a given game. - Queries each plugin's /check-applicable endpoint and aggregates results. - Plugins that don't expose this endpoint are assumed to be always applicable. + For each analysis: + 1. Check if game can be converted to the required format (orchestrator's job) + 2. If convertible, ask plugin about game-specific constraints + 3. Plugin only checks constraints like "is zero-sum?", not format availability """ - import httpx - from app.plugins import plugin_manager - store = get_game_store() game = store.get(game_id) if game is None: return {"error": f"Game not found: {game_id}"} + conversion_registry = get_conversion_registry() + native_format = getattr(game, "format_name", None) results: dict[str, dict] = {} - # Prepare game data with EFG content for extensive-form games - game_data = game.model_dump() - if getattr(game, "format_name", None) == "extensive": - from shared import export_to_efg + # Cache converted games to avoid redundant conversions + converted_games: dict[str, dict] = {} + + def get_game_in_format(target_format: str) -> tuple[dict | None, str | None]: + """Get game data in target format, with caching. Returns (game_data, error).""" + if target_format in converted_games: + return converted_games[target_format], None + + if native_format == target_format: + game_data = game.model_dump() + # Add EFG content for extensive-form games + if target_format == "extensive": + try: + game_data["efg_content"] = export_to_efg(game_data) + except ValueError as e: + return None, f"EFG export failed: {e}" + converted_games[target_format] = game_data + return game_data, None + + # Try conversion + check = conversion_registry.check(game, target_format, quick=True) + if not check.possible: + reason = ( + ", ".join(check.blockers) if check.blockers else "no conversion path" + ) + return None, f"Cannot convert to {target_format} format: {reason}" + try: - game_data["efg_content"] = export_to_efg(game_data) - except Exception: - pass # Continue without EFG content + converted = conversion_registry.convert(game, target_format) + game_data = converted.model_dump() + if target_format == "extensive": + game_data["efg_content"] = export_to_efg(game_data) + converted_games[target_format] = game_data + return game_data, None + except ValueError as e: + return None, f"Conversion failed: {e}" for name, pp in plugin_manager.plugins.items(): if not pp.healthy or not pp.url: continue - # Try to call /check-applicable on the plugin - try: - response = httpx.post( - f"{pp.url}/check-applicable", - json={"game": game_data}, - timeout=2.0, - ) - if response.status_code == 200: - data = response.json() - # Merge analysis results - for analysis_name, status in data.get("analyses", {}).items(): - results[analysis_name] = status - elif response.status_code == 404: - # Plugin doesn't support check-applicable, assume all enabled - for analysis in pp.analyses: - analysis_name = analysis.get("name") - if analysis_name and analysis_name not in results: + for analysis in pp.analyses: + analysis_name = analysis.get("name") + if not analysis_name: + continue + + applicable_to = analysis.get("applicable_to", []) + if not applicable_to: + # No format requirement - always applicable + results[analysis_name] = {"applicable": True} + continue + + # Find a format we can provide + game_data = None + format_error = None + for target_format in applicable_to: + game_data, format_error = get_game_in_format(target_format) + if game_data is not None: + break + + if game_data is None: + # Can't convert to any required format + results[analysis_name] = {"applicable": False, "reason": format_error} + continue + + # Format is available - ask plugin about game-specific constraints + try: + response = httpx.post( + f"{pp.url}/check-applicable", + json={"game": game_data}, + timeout=2.0, + ) + if response.status_code == 200: + data = response.json() + plugin_result = data.get("analyses", {}).get(analysis_name) + if plugin_result: + results[analysis_name] = plugin_result + else: results[analysis_name] = {"applicable": True} - except httpx.RequestError: - # Plugin unreachable or error, assume all enabled - for analysis in pp.analyses: - analysis_name = analysis.get("name") - if analysis_name and analysis_name not in results: + elif response.status_code == 404: + # Plugin doesn't support check-applicable - format is enough results[analysis_name] = {"applicable": True} + else: + results[analysis_name] = {"applicable": True} + except httpx.RequestError: + # Plugin unreachable - assume applicable if format works + results[analysis_name] = {"applicable": True} return {"game_id": game_id, "analyses": results} @@ -222,15 +283,13 @@ def compile_game(plugin_name: str, target: str, request: dict) -> dict: Request body should contain: { source_code: str, filename?: str } """ - from app.plugins import plugin_manager - pp = plugin_manager.get_plugin(plugin_name) if pp is None or not pp.healthy: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found or unhealthy") + raise HTTPException( + status_code=404, detail=f"Plugin '{plugin_name}' not found or unhealthy" + ) # Forward request to plugin - import httpx try: resp = httpx.post( f"{pp.url}/compile/{target}", @@ -240,11 +299,9 @@ def compile_game(plugin_name: str, target: str, request: dict) -> dict: resp.raise_for_status() return resp.json() except httpx.HTTPStatusError as e: - from fastapi import HTTPException detail = e.response.json() if e.response.content else str(e) raise HTTPException(status_code=e.response.status_code, detail=detail) except httpx.RequestError as e: - from fastapi import HTTPException raise HTTPException(status_code=502, detail=f"Plugin communication error: {e}") diff --git a/app/models/__init__.py b/app/models/__init__.py index 7df66fe..d7f1613 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,18 +1,21 @@ """Game models for the workbench.""" + from typing import Union from app.models.extensive_form import Action, DecisionNode, ExtensiveFormGame, Outcome from app.models.normal_form import NormalFormGame from app.models.maid import MAIDEdge, MAIDGame, MAIDNode, TabularCPD from app.models.vegas import VegasGame +from app.models.efg_string import EfgStringGame # Type alias for any game type - used across plugins and converters -AnyGame = Union[ExtensiveFormGame, NormalFormGame, MAIDGame, VegasGame] +AnyGame = Union[ExtensiveFormGame, NormalFormGame, MAIDGame, VegasGame, EfgStringGame] __all__ = [ "Action", "AnyGame", "DecisionNode", + "EfgStringGame", "ExtensiveFormGame", "MAIDEdge", "MAIDGame", diff --git a/app/models/efg_string.py b/app/models/efg_string.py new file mode 100644 index 0000000..d52217d --- /dev/null +++ b/app/models/efg_string.py @@ -0,0 +1,24 @@ +"""Gambit EFG text format model.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class EfgStringGame(BaseModel): + """Game in Gambit's standard EFG text format. + + This is the industry-standard format used by Gambit, OpenSpiel, and other + game theory tools. The efg_content field contains the actual EFG string. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + id: str + title: str + players: list[str] + efg_content: str = Field(description="Gambit EFG text format string") + tags: list[str] = Field(default_factory=list) + format_name: Literal["efg"] = "efg" diff --git a/app/models/extensive_form.py b/app/models/extensive_form.py index bb42940..2556f34 100644 --- a/app/models/extensive_form.py +++ b/app/models/extensive_form.py @@ -19,8 +19,12 @@ class Action(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) label: str - probability: float | None = Field(default=None, description="Behavior profile probability") - target: str | None = Field(default=None, description="ID of the node this action leads to") + probability: float | None = Field( + default=None, description="Behavior profile probability" + ) + target: str | None = Field( + default=None, description="ID of the node this action leads to" + ) warning: str | None = None diff --git a/app/models/maid.py b/app/models/maid.py index fff6e59..d399863 100644 --- a/app/models/maid.py +++ b/app/models/maid.py @@ -3,6 +3,7 @@ Represents games as causal DAGs with decision, utility, and chance nodes. Used for modeling strategic interactions with causal structure. """ + from __future__ import annotations from typing import Any, Literal diff --git a/app/models/normal_form.py b/app/models/normal_form.py index 2e844bd..f42e77a 100644 --- a/app/models/normal_form.py +++ b/app/models/normal_form.py @@ -3,6 +3,7 @@ Represents games as a payoff matrix rather than a tree. Used for 2-player simultaneous games. """ + from __future__ import annotations from typing import Literal diff --git a/app/models/vegas.py b/app/models/vegas.py index 89c8833..64db5fc 100644 --- a/app/models/vegas.py +++ b/app/models/vegas.py @@ -3,6 +3,7 @@ Vegas games are stored as source code and converted to MAID/EFG/NFG for analysis, similar to how EFG/NFG can be converted between each other. """ + from __future__ import annotations from typing import Literal diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 8602e9e..5d2ede4 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -1,4 +1,5 @@ """Plugin package with auto-discovery hooks.""" + from __future__ import annotations import importlib @@ -50,7 +51,9 @@ def _register_plugin(pp) -> None: registry.register_analysis(remote) logger.info( "Registered remote analysis: %s (from %s at %s)", - remote.name, pp.config.name, pp.url, + remote.name, + pp.config.name, + pp.url, ) # Register format parsers from plugins @@ -59,7 +62,8 @@ def _register_plugin(pp) -> None: register_format(fmt, parser, None) logger.info( "Registered remote format: %s from %s", - fmt, pp.config.name, + fmt, + pp.config.name, ) # Register conversions from plugins @@ -73,7 +77,10 @@ def _register_plugin(pp) -> None: conversion_registry.register(remote_conv) logger.info( "Registered remote conversion: %s to %s (from %s at %s)", - conv["source"], conv["target"], pp.config.name, pp.url, + conv["source"], + conv["target"], + pp.config.name, + pp.url, ) @@ -95,7 +102,9 @@ def register_healthy_plugins() -> list[str]: return newly_registered -def start_remote_plugins(project_root: Path | None = None, background: bool = False) -> dict[str, bool]: +def start_remote_plugins( + project_root: Path | None = None, background: bool = False +) -> dict[str, bool]: """Discover Docker Compose-managed plugins and register their analyses. If background=True, discovers plugins in background and returns immediately. @@ -106,6 +115,7 @@ def start_remote_plugins(project_root: Path | None = None, background: bool = Fa plugin_manager.load_config(project_root) if background: + def _discover_and_register(): plugin_manager.start_all(background=False) register_healthy_plugins() diff --git a/app/plugins/dominance.py b/app/plugins/dominance.py index 635213b..4de5e47 100644 --- a/app/plugins/dominance.py +++ b/app/plugins/dominance.py @@ -1,4 +1,5 @@ """Dominance analysis plugin - identifies strictly dominated strategies.""" + from __future__ import annotations from itertools import product @@ -20,7 +21,9 @@ class DominatedStrategy(BaseModel): player: str dominated: str # The dominated strategy label dominator: str # The strategy that dominates it - dominated_at_node: str # Node ID where the dominated action is taken (or strategy name for NFG) + dominated_at_node: ( + str # Node ID where the dominated action is taken (or strategy name for NFG) + ) class DominancePlugin: @@ -42,8 +45,10 @@ def run(self, game: AnyGame, config: dict | None = None) -> AnalysisResult: elif isinstance(game, ExtensiveFormGame): return self._run_extensive_form(game) else: - raise ValueError(f"Unsupported game type for dominance analysis: {type(game)}") - + raise ValueError( + f"Unsupported game type for dominance analysis: {type(game)}" + ) + def _run_normal_form(self, game: NormalFormGame) -> AnalysisResult: """Run dominance analysis on a normal form game.""" dominated: list[dict[str, Any]] = [] @@ -99,7 +104,9 @@ def _run_normal_form(self, game: NormalFormGame) -> AnalysisResult: details={"dominated_strategies": dominated}, ) - def _run_extensive_form(self, game: ExtensiveFormGame, config: dict | None = None) -> AnalysisResult: + def _run_extensive_form( + self, game: ExtensiveFormGame, config: dict | None = None + ) -> AnalysisResult: """Run dominance analysis.""" dominated: list[dict[str, Any]] = [] @@ -149,7 +156,9 @@ def _run_extensive_form(self, game: ExtensiveFormGame, config: dict | None = Non unique_dominated.append(d) summary = self.summarize( - AnalysisResult(summary="", details={"dominated_strategies": unique_dominated}) + AnalysisResult( + summary="", details={"dominated_strategies": unique_dominated} + ) ) return AnalysisResult( summary=summary, diff --git a/app/plugins/validation.py b/app/plugins/validation.py index 5d3c3a3..87b2c15 100644 --- a/app/plugins/validation.py +++ b/app/plugins/validation.py @@ -1,4 +1,5 @@ """Validation plugin - checks game structure for errors and warnings.""" + from __future__ import annotations from collections import deque @@ -28,7 +29,7 @@ def run(self, game: AnyGame, config: dict | None = None) -> AnalysisResult: return self._validate_extensive_form(game) else: raise ValueError(f"Unsupported game type for validation: {type(game)}") - + def _validate_normal_form(self, game: NormalFormGame) -> AnalysisResult: """Validate a normal form game.""" errors: list[str] = [] @@ -36,7 +37,9 @@ def _validate_normal_form(self, game: NormalFormGame) -> AnalysisResult: # Check: Exactly 2 players if len(game.players) != 2: - errors.append(f"Normal form requires exactly 2 players, got {len(game.players)}") + errors.append( + f"Normal form requires exactly 2 players, got {len(game.players)}" + ) # Check: Strategy counts match payoff dimensions num_rows = len(game.strategies[0]) @@ -80,8 +83,13 @@ def _validate_extensive_form(self, game: ExtensiveFormGame) -> AnalysisResult: for node_id, node in game.nodes.items(): for action in node.actions: if action.target is None: - errors.append(f"Action '{action.label}' in node '{node_id}' has no target") - elif action.target not in game.nodes and action.target not in game.outcomes: + errors.append( + f"Action '{action.label}' in node '{node_id}' has no target" + ) + elif ( + action.target not in game.nodes + and action.target not in game.outcomes + ): errors.append( f"Action '{action.label}' in node '{node_id}' " f"points to non-existent target '{action.target}'" diff --git a/app/routes/analyses.py b/app/routes/analyses.py index 2cc7bc5..888f71b 100644 --- a/app/routes/analyses.py +++ b/app/routes/analyses.py @@ -93,7 +93,9 @@ def run_game_analyses( try: start_time = time.perf_counter() - result = plugin.run(compatible_game, config=plugin_config if plugin_config else None) + result = plugin.run( + compatible_game, config=plugin_config if plugin_config else None + ) elapsed_ms = int((time.perf_counter() - start_time) * 1000) # Add timing to result details @@ -101,20 +103,24 @@ def run_game_analyses( summary=result.summary, details={**result.details, "computation_time_ms": elapsed_ms}, ) - results.append(PluginAnalysisResult( - plugin_name=plugin.name, - result=timed_result, - )) + results.append( + PluginAnalysisResult( + plugin_name=plugin.name, + result=timed_result, + ) + ) logger.info("Analysis complete: %s (%dms)", plugin.name, elapsed_ms) except Exception as e: logger.error("Analysis failed (%s): %s", plugin.name, e) # Sanitize error - include type but not potentially sensitive details - results.append(PluginAnalysisResult( - plugin_name=plugin.name, - result=AnalysisResult( - summary=f"{plugin.name}: error", - details={"error": f"Analysis failed: {type(e).__name__}"}, - ), - )) + results.append( + PluginAnalysisResult( + plugin_name=plugin.name, + result=AnalysisResult( + summary=f"{plugin.name}: error", + details={"error": f"Analysis failed: {type(e).__name__}"}, + ), + ) + ) return results diff --git a/app/routes/games.py b/app/routes/games.py index 15d2c90..46b6e9d 100644 --- a/app/routes/games.py +++ b/app/routes/games.py @@ -6,12 +6,17 @@ from fastapi import APIRouter, Depends, UploadFile from starlette.concurrency import run_in_threadpool -from app.core.errors import not_found, bad_request, conversion_failed, invalid_format, parse_failed +from app.core.errors import ( + not_found, + bad_request, + conversion_failed, + invalid_format, + parse_failed, +) from app.core.store import AnyGame, GameStore, GameSummary, is_supported_format from app.dependencies import get_game_store from app.formats import parse_game - logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["games"]) @@ -52,7 +57,9 @@ def get_game_summary(game_id: str, store: GameStoreDep) -> GameSummary: @router.get("/games/{game_id}/as/{target_format}") -def get_game_as_format(game_id: str, target_format: str, store: GameStoreDep) -> AnyGame: +def get_game_as_format( + game_id: str, target_format: str, store: GameStoreDep +) -> AnyGame: """Get a game converted to a specific format. Args: @@ -117,4 +124,3 @@ async def upload_game(file: UploadFile, store: GameStoreDep) -> AnyGame: except Exception as e: logger.error("Upload failed: %s", e) raise parse_failed() - diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 7ce80a0..1da638c 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -55,7 +55,10 @@ def run_analysis(cfg: dict | None) -> dict: start = time.perf_counter() result = analysis_plugin.run(game, config=cfg) elapsed_ms = int((time.perf_counter() - start) * 1000) - return {"summary": result.summary, "details": {**result.details, "computation_time_ms": elapsed_ms}} + return { + "summary": result.summary, + "details": {**result.details, "computation_time_ms": elapsed_ms}, + } task_id = tasks.submit( owner=owner, diff --git a/app/static_mount.py b/app/static_mount.py index 1488095..db44f35 100644 --- a/app/static_mount.py +++ b/app/static_mount.py @@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse + def mount_frontend(app: FastAPI) -> None: frontend_dir = Path(__file__).resolve().parent.parent / "frontend" dist_dir = frontend_dir / "dist" diff --git a/docker/Dockerfile.vegas b/docker/Dockerfile.vegas index 6dc75d6..981d560 100644 --- a/docker/Dockerfile.vegas +++ b/docker/Dockerfile.vegas @@ -5,7 +5,13 @@ FROM thrones-base:latest WORKDIR /app/plugins/vegas -# No additional dependencies beyond base image +# Install Java runtime for Vegas JAR execution +RUN apt-get update && apt-get install -y --no-install-recommends \ + default-jre-headless \ + && rm -rf /var/lib/apt/lists/* + +# Copy Vegas JAR +COPY plugins/vegas/lib/ ./lib/ # Copy plugin code COPY plugins/vegas/vegas_plugin/ ./vegas_plugin/ diff --git a/plugins/egttools/egttools_plugin/__main__.py b/plugins/egttools/egttools_plugin/__main__.py index 05add46..a768699 100644 --- a/plugins/egttools/egttools_plugin/__main__.py +++ b/plugins/egttools/egttools_plugin/__main__.py @@ -3,6 +3,7 @@ Run with: python -m egttools_plugin --port=PORT Implements the plugin HTTP contract (API v1). """ + from __future__ import annotations import argparse @@ -146,13 +147,15 @@ def health() -> dict: def info() -> dict: analyses_info = [] for a in ANALYSES.values(): - analyses_info.append({ - "name": a["name"], - "description": a["description"], - "applicable_to": a["applicable_to"], - "continuous": a["continuous"], - "config_schema": a["config_schema"], - }) + analyses_info.append( + { + "name": a["name"], + "description": a["description"], + "applicable_to": a["applicable_to"], + "continuous": a["continuous"], + "config_schema": a["config_schema"], + } + ) return { "api_version": API_VERSION, "plugin_version": PLUGIN_VERSION, diff --git a/plugins/egttools/egttools_plugin/fixation.py b/plugins/egttools/egttools_plugin/fixation.py index 077fded..7af42f2 100644 --- a/plugins/egttools/egttools_plugin/fixation.py +++ b/plugins/egttools/egttools_plugin/fixation.py @@ -1,4 +1,5 @@ """Fixation probability and evolutionary stability analysis.""" + from __future__ import annotations from typing import Any diff --git a/plugins/egttools/egttools_plugin/replicator.py b/plugins/egttools/egttools_plugin/replicator.py index cba198c..7cd7d36 100644 --- a/plugins/egttools/egttools_plugin/replicator.py +++ b/plugins/egttools/egttools_plugin/replicator.py @@ -1,4 +1,5 @@ """Replicator dynamics analysis for normal-form games.""" + from __future__ import annotations from typing import Any @@ -116,9 +117,7 @@ def run_replicator_dynamics( # Find equilibrium strategies (those with significant frequency at end) final_state = trajectory[-1] dominant_strategies = [ - strategy_labels[i] - for i, freq in enumerate(final_state) - if freq > 0.01 + strategy_labels[i] for i, freq in enumerate(final_state) if freq > 0.01 ] if len(dominant_strategies) == 1: diff --git a/plugins/egttools/tests/test_replicator.py b/plugins/egttools/tests/test_replicator.py index 78cd153..b869c4a 100644 --- a/plugins/egttools/tests/test_replicator.py +++ b/plugins/egttools/tests/test_replicator.py @@ -33,7 +33,7 @@ def hawk_dove_nfg(): "strategies": [["Hawk", "Dove"], ["Hawk", "Dove"]], "payoffs": [ [[-1, -1], [2, 0]], # Player 1 plays Hawk - [[0, 2], [1, 1]], # Player 1 plays Dove + [[0, 2], [1, 1]], # Player 1 plays Dove ], } @@ -64,7 +64,7 @@ def test_converts_payoffs_correctly(self, prisoners_dilemma_nfg): # Check player 1's payoffs assert matrix[0, 0] == -1 # (C, C) assert matrix[0, 1] == -3 # (C, D) - assert matrix[1, 0] == 0 # (D, C) + assert matrix[1, 0] == 0 # (D, C) assert matrix[1, 1] == -2 # (D, D) def test_raises_on_empty_payoffs(self): @@ -87,7 +87,9 @@ def test_returns_valid_result(self, prisoners_dilemma_nfg): def test_trajectory_has_correct_length(self, prisoners_dilemma_nfg): """Test that trajectory matches time_steps.""" time_steps = 50 - result = run_replicator_dynamics(prisoners_dilemma_nfg, {"time_steps": time_steps}) + result = run_replicator_dynamics( + prisoners_dilemma_nfg, {"time_steps": time_steps} + ) trajectory = result["details"]["trajectory"] assert len(trajectory) == time_steps + 1 # Includes initial state @@ -103,8 +105,7 @@ def test_frequencies_sum_to_one(self, prisoners_dilemma_nfg): def test_pd_converges_to_defect(self, prisoners_dilemma_nfg): """Test that Prisoner's Dilemma converges to Defect.""" result = run_replicator_dynamics( - prisoners_dilemma_nfg, - {"time_steps": 500, "initial_population": [0.5, 0.5]} + prisoners_dilemma_nfg, {"time_steps": 500, "initial_population": [0.5, 0.5]} ) final_state = result["details"]["final_state"] @@ -115,8 +116,7 @@ def test_custom_initial_population(self, prisoners_dilemma_nfg): """Test that custom initial population is used.""" initial = [0.8, 0.2] result = run_replicator_dynamics( - prisoners_dilemma_nfg, - {"time_steps": 10, "initial_population": initial} + prisoners_dilemma_nfg, {"time_steps": 10, "initial_population": initial} ) initial_state = result["details"]["initial_state"] @@ -128,8 +128,7 @@ class TestEvolutionaryStability: def test_returns_valid_result(self, prisoners_dilemma_nfg): """Test that evolutionary stability returns a valid structure.""" result = run_evolutionary_stability( - prisoners_dilemma_nfg, - {"population_size": 50} + prisoners_dilemma_nfg, {"population_size": 50} ) assert "summary" in result @@ -140,8 +139,7 @@ def test_returns_valid_result(self, prisoners_dilemma_nfg): def test_stationary_distribution_sums_to_one(self, prisoners_dilemma_nfg): """Test that stationary distribution is valid.""" result = run_evolutionary_stability( - prisoners_dilemma_nfg, - {"population_size": 50} + prisoners_dilemma_nfg, {"population_size": 50} ) dist = result["details"]["stationary_distribution"] @@ -152,7 +150,7 @@ def test_pd_defect_dominates(self, prisoners_dilemma_nfg): """Test that Defect dominates or equals Cooperate in Prisoner's Dilemma.""" result = run_evolutionary_stability( prisoners_dilemma_nfg, - {"population_size": 100, "intensity_of_selection": 1.0} + {"population_size": 100, "intensity_of_selection": 1.0}, ) dist = result["details"]["stationary_distribution"] diff --git a/plugins/gambit/gambit_plugin/__main__.py b/plugins/gambit/gambit_plugin/__main__.py index f24b897..8b50edb 100644 --- a/plugins/gambit/gambit_plugin/__main__.py +++ b/plugins/gambit/gambit_plugin/__main__.py @@ -3,6 +3,7 @@ Run with: python -m gambit_plugin --port=PORT Implements the plugin HTTP contract (API v1). """ + from __future__ import annotations import argparse @@ -48,10 +49,21 @@ "config_schema": { "solver": { "type": "string", - "enum": ["exhaustive", "quick", "pure", "logit", "approximate", "lp", "liap"], + "enum": [ + "exhaustive", + "quick", + "pure", + "logit", + "approximate", + "lp", + "liap", + ], }, "max_equilibria": {"type": "integer"}, - "maxregret": {"type": "number", "description": "Max regret for liap solver (default 1e-6)"}, + "maxregret": { + "type": "number", + "description": "Max regret for liap solver (default 1e-6)", + }, }, "run": run_nash, }, @@ -87,7 +99,10 @@ "applicable_to": ["extensive", "normal"], "continuous": True, "config_schema": { - "tau": {"type": "number", "description": "Poisson parameter for level distribution (default 1.5)"}, + "tau": { + "type": "number", + "description": "Poisson parameter for level distribution (default 1.5)", + }, }, "run": run_levelk, }, @@ -175,13 +190,15 @@ def health() -> dict: def info() -> dict: analyses_info = [] for a in ANALYSES.values(): - analyses_info.append({ - "name": a["name"], - "description": a["description"], - "applicable_to": a["applicable_to"], - "continuous": a["continuous"], - "config_schema": a["config_schema"], - }) + analyses_info.append( + { + "name": a["name"], + "description": a["description"], + "applicable_to": a["applicable_to"], + "continuous": a["continuous"], + "config_schema": a["config_schema"], + } + ) return { "api_version": API_VERSION, "plugin_version": PLUGIN_VERSION, diff --git a/plugins/gambit/gambit_plugin/gambit_utils.py b/plugins/gambit/gambit_plugin/gambit_utils.py index d1ba77e..b703a73 100644 --- a/plugins/gambit/gambit_plugin/gambit_utils.py +++ b/plugins/gambit/gambit_plugin/gambit_utils.py @@ -3,6 +3,7 @@ Moved from app/core/gambit_utils.py for plugin isolation. Operates on plain dicts (deserialized game JSON). """ + from __future__ import annotations from itertools import product @@ -51,9 +52,13 @@ def extensive_to_gambit_table( player.label = player_name for strat_index, strategy in enumerate(strategies[player_name]): labels = [strategy[node_id] for node_id in sorted(strategy.keys())] - player.strategies[strat_index].label = "/".join(labels) if labels else "No moves" + player.strategies[strat_index].label = ( + "/".join(labels) if labels else "No moves" + ) - for profile_indices in product(*[range(len(strategies[player])) for player in players]): + for profile_indices in product( + *[range(len(strategies[player])) for player in players] + ): profile = { player: strategies[player][idx] for player, idx in zip(players, profile_indices, strict=True) diff --git a/plugins/gambit/gambit_plugin/iesds.py b/plugins/gambit/gambit_plugin/iesds.py index 4bb0576..91296ef 100644 --- a/plugins/gambit/gambit_plugin/iesds.py +++ b/plugins/gambit/gambit_plugin/iesds.py @@ -1,4 +1,5 @@ """IESDS analysis - Iterated Elimination of Strictly Dominated Strategies.""" + from __future__ import annotations from typing import Any @@ -9,7 +10,9 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs -def run_iesds(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_iesds( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Run IESDS on a game. Args: @@ -39,11 +42,13 @@ def run_iesds(game: dict[str, Any], config: dict[str, Any] | None = None) -> dic for player in gambit_game.players: for strategy in player.strategies: if strategy in support and strategy not in new_support: - eliminated_this_round.append({ - "player": player.label, - "strategy": strategy.label, - "round": rounds, - }) + eliminated_this_round.append( + { + "player": player.label, + "strategy": strategy.label, + "round": rounds, + } + ) if not eliminated_this_round: break diff --git a/plugins/gambit/gambit_plugin/levelk.py b/plugins/gambit/gambit_plugin/levelk.py index 0fe603d..3c81ab6 100644 --- a/plugins/gambit/gambit_plugin/levelk.py +++ b/plugins/gambit/gambit_plugin/levelk.py @@ -7,6 +7,7 @@ - Level-2: Best responds to mix of Level-0 and Level-1 - etc. """ + from __future__ import annotations from typing import Any @@ -18,7 +19,9 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs -def run_levelk(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_levelk( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Compute Cognitive Hierarchy predictions for a game. The Cognitive Hierarchy model assumes players have different levels @@ -65,7 +68,12 @@ def run_levelk(game: dict[str, Any], config: dict[str, Any] | None = None) -> di except (ValueError, IndexError, RuntimeError, TypeError) as e: return { "summary": f"Cognitive Hierarchy computation failed: {e}", - "details": {"levels": [], "tau": tau, "solver": "gambit-coghier", "error": str(e)}, + "details": { + "levels": [], + "tau": tau, + "solver": "gambit-coghier", + "error": str(e), + }, } @@ -74,7 +82,7 @@ def _clean_float(value: float, tolerance: float = 1e-6) -> float: if abs(value) < tolerance: return 0.0 - common_fractions = [0.5, 1/3, 2/3, 0.25, 0.75] + common_fractions = [0.5, 1 / 3, 2 / 3, 0.25, 0.75] for frac in common_fractions: if abs(value - frac) < tolerance: return frac diff --git a/plugins/gambit/gambit_plugin/nash.py b/plugins/gambit/gambit_plugin/nash.py index 69efdee..b79e3d0 100644 --- a/plugins/gambit/gambit_plugin/nash.py +++ b/plugins/gambit/gambit_plugin/nash.py @@ -1,4 +1,5 @@ """Nash equilibrium analysis - standalone for plugin service.""" + from __future__ import annotations from typing import Any @@ -9,7 +10,9 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs -def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_nash( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Compute Nash equilibria for a game. Args: @@ -44,7 +47,9 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict if result is None or (stop_after > 1 and len(result.equilibria) < stop_after): try: - result = gbt.nash.lcp_solve(gambit_game, stop_after=stop_after, rational=False) + result = gbt.nash.lcp_solve( + gambit_game, stop_after=stop_after, rational=False + ) solver_name = "gambit-lcp" except (ValueError, IndexError, RuntimeError): pass @@ -56,7 +61,12 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict except (ValueError, IndexError, RuntimeError) as e: return { "summary": f"All Nash solvers failed: {e}", - "details": {"equilibria": [], "solver": "none", "exhaustive": False, "error": str(e)}, + "details": { + "equilibria": [], + "solver": "none", + "exhaustive": False, + "error": str(e), + }, } exhaustive = len(result.equilibria) < stop_after @@ -67,7 +77,12 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict except (ValueError, IndexError, RuntimeError) as e: return { "summary": f"Pure strategy solver failed: {e}", - "details": {"equilibria": [], "solver": "gambit-enumpure", "exhaustive": False, "error": str(e)}, + "details": { + "equilibria": [], + "solver": "gambit-enumpure", + "exhaustive": False, + "error": str(e), + }, } solver_name = "gambit-enumpure" exhaustive = True @@ -99,7 +114,12 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict except (ValueError, IndexError, RuntimeError) as e: return { "summary": f"Logit solver failed: {e}", - "details": {"equilibria": [], "solver": "gambit-logit", "exhaustive": False, "error": str(e)}, + "details": { + "equilibria": [], + "solver": "gambit-logit", + "exhaustive": False, + "error": str(e), + }, } solver_name = "gambit-logit" exhaustive = False @@ -111,7 +131,12 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict except (ValueError, IndexError, RuntimeError) as e: return { "summary": f"LP solver failed (requires 2-player constant-sum game): {e}", - "details": {"equilibria": [], "solver": "gambit-lp", "exhaustive": False, "error": str(e)}, + "details": { + "equilibria": [], + "solver": "gambit-lp", + "exhaustive": False, + "error": str(e), + }, } solver_name = "gambit-lp" exhaustive = True @@ -124,7 +149,12 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict except (ValueError, IndexError, RuntimeError) as e: return { "summary": f"Lyapunov solver failed: {e}", - "details": {"equilibria": [], "solver": "gambit-liap", "exhaustive": False, "error": str(e)}, + "details": { + "equilibria": [], + "solver": "gambit-liap", + "exhaustive": False, + "error": str(e), + }, } solver_name = "gambit-liap" exhaustive = False @@ -136,7 +166,12 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict except (ValueError, IndexError, RuntimeError) as e: return { "summary": f"Exhaustive solver failed: {e}", - "details": {"equilibria": [], "solver": "gambit-enummixed", "exhaustive": False, "error": str(e)}, + "details": { + "equilibria": [], + "solver": "gambit-enummixed", + "exhaustive": False, + "error": str(e), + }, } solver_name = "gambit-enummixed" exhaustive = True @@ -147,7 +182,12 @@ def run_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict # Conversion to our format failed - likely a pygambit internal issue return { "summary": f"Error processing equilibrium results: {e}", - "details": {"equilibria": [], "solver": solver_name, "exhaustive": False, "error": str(e)}, + "details": { + "equilibria": [], + "solver": solver_name, + "exhaustive": False, + "error": str(e), + }, } count = len(equilibria) @@ -175,12 +215,23 @@ def _clean_float(value: float, tolerance: float = 1e-6) -> float: return 0.0 common_fractions = [ - 0.0, 1.0, 0.5, - 1 / 3, 2 / 3, - 0.25, 0.75, - 0.2, 0.4, 0.6, 0.8, - 1 / 6, 5 / 6, - 1 / 8, 3 / 8, 5 / 8, 7 / 8, + 0.0, + 1.0, + 0.5, + 1 / 3, + 2 / 3, + 0.25, + 0.75, + 0.2, + 0.4, + 0.6, + 0.8, + 1 / 6, + 5 / 6, + 1 / 8, + 3 / 8, + 5 / 8, + 7 / 8, ] for frac in common_fractions: if abs(value - frac) < tolerance: @@ -198,9 +249,13 @@ def _to_equilibrium(game: gbt.Game, eq) -> dict[str, Any]: strategies: dict[str, dict[str, float]] = {} for strategy, probability in eq: player_label = strategy.player.label - strategies.setdefault(player_label, {})[strategy.label] = _clean_float(float(probability)) + strategies.setdefault(player_label, {})[strategy.label] = _clean_float( + float(probability) + ) - payoffs = {player.label: _clean_float(float(eq.payoff(player))) for player in game.players} + payoffs = { + player.label: _clean_float(float(eq.payoff(player))) for player in game.players + } pure = all(p in (0.0, 1.0) for probs in strategies.values() for p in probs.values()) if pure: diff --git a/plugins/gambit/gambit_plugin/parsers.py b/plugins/gambit/gambit_plugin/parsers.py index 202b953..bcb66be 100644 --- a/plugins/gambit/gambit_plugin/parsers.py +++ b/plugins/gambit/gambit_plugin/parsers.py @@ -3,6 +3,7 @@ Returns plain dicts matching the ExtensiveFormGame and NormalFormGame schemas, suitable for JSON serialization over HTTP. """ + from __future__ import annotations import io @@ -114,7 +115,7 @@ def _traverse_efg_node( node_id_map[id(node)] = node_id player = node.player - is_chance = player is not None and hasattr(player, 'is_chance') and player.is_chance + is_chance = player is not None and hasattr(player, "is_chance") and player.is_chance if is_chance: player_name = "Chance" @@ -167,7 +168,9 @@ def _traverse_efg_node( return node_id -def _nfg_to_normal_form_dict(gambit_game: gbt.Game, source_file: str = "") -> dict[str, Any]: +def _nfg_to_normal_form_dict( + gambit_game: gbt.Game, source_file: str = "" +) -> dict[str, Any]: """Convert a 2-player normal form game to dict matching NormalFormGame schema.""" players = tuple( p.label or f"Player{i+1}" for i, p in enumerate(gambit_game.players) @@ -205,7 +208,9 @@ def _nfg_to_normal_form_dict(gambit_game: gbt.Game, source_file: str = "") -> di } -def _nfg_to_extensive_dict(gambit_game: gbt.Game, source_file: str = "") -> dict[str, Any]: +def _nfg_to_extensive_dict( + gambit_game: gbt.Game, source_file: str = "" +) -> dict[str, Any]: """Convert a normal form game to extensive form dict (for 3+ players).""" players = [p.label or f"Player{i+1}" for i, p in enumerate(gambit_game.players)] @@ -238,9 +243,7 @@ def get_outcome_id(strategy_indices: tuple[int, ...]) -> str: payoff = gambit_game[profile_key][player] payoffs[players[player_idx]] = float(payoff) - strat_labels = [ - strategies[i][idx] for i, idx in enumerate(strategy_indices) - ] + strat_labels = [strategies[i][idx] for i, idx in enumerate(strategy_indices)] label = ", ".join(strat_labels) outcomes[outcome_id] = {"label": label, "payoffs": payoffs} @@ -264,7 +267,9 @@ def build_subtree( actions = [] for strat_idx, strat_label in enumerate(player_strategies): new_prefix = strategy_prefix + (strat_idx,) - next_info_set = f"h_{player_idx + 1}" if player_idx + 1 < len(players) else None + next_info_set = ( + f"h_{player_idx + 1}" if player_idx + 1 < len(players) else None + ) target = build_subtree(player_idx + 1, new_prefix, next_info_set) actions.append({"label": strat_label, "target": target}) diff --git a/plugins/gambit/gambit_plugin/qre.py b/plugins/gambit/gambit_plugin/qre.py index 319257e..b8484fb 100644 --- a/plugins/gambit/gambit_plugin/qre.py +++ b/plugins/gambit/gambit_plugin/qre.py @@ -4,6 +4,7 @@ proportional to the cost of those errors. As lambda (rationality) increases, behavior converges to Nash equilibrium. """ + from __future__ import annotations from typing import Any @@ -14,7 +15,9 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs -def run_qre(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_qre( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Compute Quantal Response Equilibrium path for a game. Returns a sequence of equilibria along the QRE correspondence, @@ -74,7 +77,7 @@ def _clean_float(value: float, tolerance: float = 1e-6) -> float: if abs(value) < tolerance: return 0.0 - common_fractions = [0.5, 1/3, 2/3, 0.25, 0.75] + common_fractions = [0.5, 1 / 3, 2 / 3, 0.25, 0.75] for frac in common_fractions: if abs(value - frac) < tolerance: return frac @@ -87,9 +90,13 @@ def _profile_to_dict(game: gbt.Game, eq) -> dict[str, Any]: strategies: dict[str, dict[str, float]] = {} for strategy, probability in eq: player_label = strategy.player.label - strategies.setdefault(player_label, {})[strategy.label] = _clean_float(float(probability)) + strategies.setdefault(player_label, {})[strategy.label] = _clean_float( + float(probability) + ) - payoffs = {player.label: _clean_float(float(eq.payoff(player))) for player in game.players} + payoffs = { + player.label: _clean_float(float(eq.payoff(player))) for player in game.players + } pure = all(p in (0.0, 1.0) for probs in strategies.values() for p in probs.values()) description = "Pure strategy QRE" if pure else "Mixed strategy QRE" diff --git a/plugins/gambit/gambit_plugin/strategies.py b/plugins/gambit/gambit_plugin/strategies.py index fb03729..b193821 100644 --- a/plugins/gambit/gambit_plugin/strategies.py +++ b/plugins/gambit/gambit_plugin/strategies.py @@ -4,6 +4,7 @@ The shared module operates on plain dicts (deserialized game JSON), which is exactly what plugins receive. """ + from __future__ import annotations # Re-export from shared module for backward compatibility diff --git a/plugins/gambit/gambit_plugin/supports.py b/plugins/gambit/gambit_plugin/supports.py index 3d6fc31..e90f6e7 100644 --- a/plugins/gambit/gambit_plugin/supports.py +++ b/plugins/gambit/gambit_plugin/supports.py @@ -4,6 +4,7 @@ that might be played with positive probability) for a game. Useful for understanding the structure of potential equilibria. """ + from __future__ import annotations from typing import Any @@ -15,7 +16,9 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs -def run_support_enum(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_support_enum( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Enumerate all possible support profiles for a game. A support profile specifies which strategies each player might @@ -65,7 +68,11 @@ def run_support_enum(game: dict[str, Any], config: dict[str, Any] | None = None) except (ValueError, IndexError, RuntimeError, TypeError) as e: return { "summary": f"Support enumeration failed: {e}", - "details": {"supports": [], "solver": "gambit-support-enum", "error": str(e)}, + "details": { + "supports": [], + "solver": "gambit-support-enum", + "error": str(e), + }, } diff --git a/plugins/gambit/gambit_plugin/verify_profile.py b/plugins/gambit/gambit_plugin/verify_profile.py index 37a5d43..414857e 100644 --- a/plugins/gambit/gambit_plugin/verify_profile.py +++ b/plugins/gambit/gambit_plugin/verify_profile.py @@ -1,4 +1,5 @@ """Profile verification analysis - checks if a strategy profile is a Nash equilibrium.""" + from __future__ import annotations from typing import Any @@ -7,7 +8,9 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs -def run_verify_profile(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_verify_profile( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Verify if a strategy profile is a Nash equilibrium. Args: diff --git a/plugins/gambit/tests/test_analyses.py b/plugins/gambit/tests/test_analyses.py index b3e9d11..15e2afe 100644 --- a/plugins/gambit/tests/test_analyses.py +++ b/plugins/gambit/tests/test_analyses.py @@ -1,4 +1,5 @@ """Tests for gambit plugin analyses (standalone, no main app dependency).""" + from __future__ import annotations import pytest @@ -7,11 +8,11 @@ from gambit_plugin.iesds import run_iesds from gambit_plugin.verify_profile import run_verify_profile - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def trust_game() -> dict: """Trust game in extensive form (as deserialized dict).""" @@ -135,7 +136,10 @@ def test_no_elimination(self, matching_pennies_nfg): def test_summary(self, prisoners_dilemma_nfg): result = run_iesds(prisoners_dilemma_nfg) - assert "eliminated" in result["summary"].lower() or "Eliminated" in result["summary"] + assert ( + "eliminated" in result["summary"].lower() + or "Eliminated" in result["summary"] + ) # --------------------------------------------------------------------------- diff --git a/plugins/gambit/tests/test_iesds.py b/plugins/gambit/tests/test_iesds.py index 6ae14be..1aac654 100644 --- a/plugins/gambit/tests/test_iesds.py +++ b/plugins/gambit/tests/test_iesds.py @@ -1,4 +1,5 @@ """Comprehensive IESDS tests for the gambit plugin.""" + from __future__ import annotations import pytest @@ -7,11 +8,11 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs from gambit_plugin.gambit_utils import normal_form_to_gambit - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def prisoners_dilemma_nfg() -> dict: """Prisoner's Dilemma - D strictly dominates C for both players.""" @@ -127,6 +128,7 @@ def iterated_dominance_game() -> dict: # IESDS analysis tests # --------------------------------------------------------------------------- + class TestIESDSPlugin: def test_prisoners_dilemma_nfg_elimination(self, prisoners_dilemma_nfg): """In PD, Cooperate is dominated by Defect for both players.""" @@ -186,12 +188,18 @@ def test_rounds_counted_correctly(self, prisoners_dilemma_nfg): def test_summarize_no_eliminations(self, matching_pennies_nfg): """Summary should indicate no dominated strategies.""" result = run_iesds(matching_pennies_nfg) - assert "No dominated strategies" in result["summary"] or "no dominated" in result["summary"].lower() + assert ( + "No dominated strategies" in result["summary"] + or "no dominated" in result["summary"].lower() + ) def test_summarize_with_eliminations(self, prisoners_dilemma_nfg): """Summary should indicate strategies were eliminated.""" result = run_iesds(prisoners_dilemma_nfg) - assert "eliminated" in result["summary"].lower() or "Eliminated" in result["summary"] + assert ( + "eliminated" in result["summary"].lower() + or "Eliminated" in result["summary"] + ) def test_result_structure(self, prisoners_dilemma_nfg): """Result should have the expected dict structure.""" @@ -207,6 +215,7 @@ def test_result_structure(self, prisoners_dilemma_nfg): # IESDS internals tests # --------------------------------------------------------------------------- + class TestIESDSPluginInternals: def test_enumerate_strategies_efg(self, prisoners_dilemma_efg): """Should enumerate strategies respecting information sets.""" @@ -243,6 +252,7 @@ def test_normal_form_to_gambit_conversion(self, prisoners_dilemma_nfg): # Iterative elimination tests # --------------------------------------------------------------------------- + class TestIESDSIterativeElimination: def test_multi_round_elimination(self, iterated_dominance_game): """Should perform multiple rounds of elimination.""" diff --git a/plugins/gambit/tests/test_nash.py b/plugins/gambit/tests/test_nash.py index a9ed3d4..1595ad5 100644 --- a/plugins/gambit/tests/test_nash.py +++ b/plugins/gambit/tests/test_nash.py @@ -1,4 +1,5 @@ """Comprehensive Nash equilibrium tests for the gambit plugin.""" + from __future__ import annotations import pytest @@ -7,11 +8,11 @@ from gambit_plugin.strategies import enumerate_strategies, resolve_payoffs from gambit_plugin.gambit_utils import normal_form_to_gambit - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def trust_game() -> dict: """Trust game in extensive form.""" @@ -157,6 +158,7 @@ def sequential_pennies() -> dict: # Nash analysis tests # --------------------------------------------------------------------------- + class TestNashPlugin: def test_run_on_trust_game(self, trust_game): result = run_nash(trust_game) @@ -217,6 +219,7 @@ def test_summary_plural(self, trust_game): # Strategy utility tests # --------------------------------------------------------------------------- + class TestStrategyUtilities: def test_enumerate_strategies(self, trust_game): strategies = enumerate_strategies(trust_game) @@ -252,6 +255,7 @@ def test_resolve_payoffs_missing_player(self, trust_game): # Information set handling tests # --------------------------------------------------------------------------- + class TestInformationSetHandling: def test_info_set_strategy_count(self, matching_pennies): """P2 should have 2 strategies, not 4, due to information set.""" @@ -294,6 +298,7 @@ def test_sequential_pennies_pure_equilibrium(self, sequential_pennies): # Gambit conversion tests # --------------------------------------------------------------------------- + class TestGambitConversion: def test_normal_form_to_gambit(self, prisoners_dilemma_nfg): gambit_game = normal_form_to_gambit(prisoners_dilemma_nfg) diff --git a/plugins/gambit/tests/test_parsers.py b/plugins/gambit/tests/test_parsers.py index e785549..8338a61 100644 --- a/plugins/gambit/tests/test_parsers.py +++ b/plugins/gambit/tests/test_parsers.py @@ -1,62 +1,63 @@ """Tests for EFG and NFG format parsers in the gambit plugin.""" + from __future__ import annotations import pytest from gambit_plugin.parsers import parse_efg, parse_nfg - # --------------------------------------------------------------------------- # EFG test data # --------------------------------------------------------------------------- -TRUST_GAME_EFG = '''\ +TRUST_GAME_EFG = """\ EFG 2 R "Trust Game" { "Alice" "Bob" } p "" 1 1 "" { "Trust" "Don't" } 0 p "" 2 1 "" { "Honor" "Betray" } 0 t "" 1 "Cooperate" { 1, 1 } t "" 2 "Betray" { -1, 2 } t "" 3 "Decline" { 0, 0 } -''' +""" -SIMPLE_GAME_EFG = '''\ +SIMPLE_GAME_EFG = """\ EFG 2 R "Simple" { "P1" "P2" } p "" 1 1 "" { "L" "R" } 0 t "" 1 "Left" { 1, 0 } t "" 2 "Right" { 0, 1 } -''' +""" # --------------------------------------------------------------------------- # NFG test data # --------------------------------------------------------------------------- -PRISONERS_DILEMMA_NFG = '''\ +PRISONERS_DILEMMA_NFG = """\ NFG 1 R "Prisoner's Dilemma" { "Player 1" "Player 2" } { { "Cooperate" "Defect" } { "Cooperate" "Defect" } } 3 3 0 5 5 0 1 1 -''' +""" -MATCHING_PENNIES_NFG = '''\ +MATCHING_PENNIES_NFG = """\ NFG 1 R "Matching Pennies" { "P1" "P2" } { { "Heads" "Tails" } { "Heads" "Tails" } } 1 -1 -1 1 -1 1 1 -1 -''' +""" -THREE_PLAYER_NFG = '''\ +THREE_PLAYER_NFG = """\ NFG 1 R "3-Player Game" { "P1" "P2" "P3" } { { "A" "B" } { "C" "D" } { "E" "F" } } 1 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 -''' +""" # --------------------------------------------------------------------------- # EFG parser tests # --------------------------------------------------------------------------- + class TestEFGParser: def test_parse_simple_game(self): game = parse_efg(SIMPLE_GAME_EFG, "simple.efg") @@ -113,6 +114,7 @@ def test_parse_empty_string_raises_error(self): # NFG parser tests # --------------------------------------------------------------------------- + class TestNFGParser: def test_parse_prisoners_dilemma(self): """2-player games should be parsed as NormalFormGame dict.""" diff --git a/plugins/gambit/tests/test_verify_profile.py b/plugins/gambit/tests/test_verify_profile.py index b0dbdfd..b6ae188 100644 --- a/plugins/gambit/tests/test_verify_profile.py +++ b/plugins/gambit/tests/test_verify_profile.py @@ -1,4 +1,5 @@ """Comprehensive Verify Profile tests for the gambit plugin.""" + from __future__ import annotations import pytest @@ -6,11 +7,11 @@ from gambit_plugin.verify_profile import run_verify_profile from gambit_plugin.gambit_utils import normal_form_to_gambit - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def matching_pennies_nfg() -> dict: """Matching Pennies - unique mixed equilibrium at (0.5, 0.5).""" @@ -98,6 +99,7 @@ def trust_game_efg() -> dict: # Verify Profile tests # --------------------------------------------------------------------------- + class TestVerifyProfilePlugin: def test_requires_profile_config(self, matching_pennies_nfg): """Should raise error if no profile provided.""" @@ -129,7 +131,9 @@ def test_verify_non_equilibrium_pd(self, prisoners_dilemma_nfg): assert result["details"]["is_equilibrium"] is False assert result["details"]["max_regret"] > 0 - assert "not" in result["summary"].lower() or "regret" in result["summary"].lower() + assert ( + "not" in result["summary"].lower() or "regret" in result["summary"].lower() + ) def test_verify_mixed_equilibrium_matching_pennies(self, matching_pennies_nfg): """(0.5, 0.5) for both players is the unique mixed equilibrium.""" @@ -218,6 +222,7 @@ def test_result_structure(self, prisoners_dilemma_nfg): # Extensive form tests # --------------------------------------------------------------------------- + class TestVerifyProfileExtensiveForm: def test_verify_equilibrium_efg(self, trust_game_efg): """(Don't, Betray) is the subgame perfect equilibrium.""" @@ -244,6 +249,7 @@ def test_non_equilibrium_efg(self, trust_game_efg): # Internals tests # --------------------------------------------------------------------------- + class TestVerifyProfileInternals: def test_normal_form_to_gambit(self, matching_pennies_nfg): """Should convert NormalFormGame dict to Gambit format.""" diff --git a/plugins/openspiel/openspiel_plugin/__main__.py b/plugins/openspiel/openspiel_plugin/__main__.py index 773fe25..d77dcea 100644 --- a/plugins/openspiel/openspiel_plugin/__main__.py +++ b/plugins/openspiel/openspiel_plugin/__main__.py @@ -187,23 +187,15 @@ def info() -> dict: @app.post("/check-applicable") def check_applicable(req: CheckApplicableRequest) -> dict: - """Check which analyses are applicable to the given game. + """Check game-specific constraints for each analysis. - Returns applicability status and reason for each analysis. + The orchestrator already verified format compatibility and conversion. + This endpoint only checks game-specific constraints (e.g., zero-sum requirement). """ results = {} - game_format = req.game.get("format_name", "") for name, analysis in ANALYSES.items(): - # First check format compatibility - if game_format not in analysis["applicable_to"]: - results[name] = { - "applicable": False, - "reason": f"Requires {' or '.join(analysis['applicable_to']).upper()} format", - } - continue - - # Then check analysis-specific constraints + # Check analysis-specific constraints (e.g., zero-sum for Exploitability) check_fn = analysis.get("check_applicable") if check_fn: check_result = check_fn(req.game) @@ -233,19 +225,7 @@ def analyze(req: AnalyzeRequest) -> dict: }, ) - game_format = req.game.get("format_name", "") - if game_format not in analysis_entry["applicable_to"]: - raise HTTPException( - status_code=400, - detail={ - "error": { - "code": "INVALID_GAME", - "message": f"Game format '{game_format}' not supported by {req.analysis}", - } - }, - ) - - # Check analysis-specific constraints + # Check analysis-specific constraints (orchestrator handles format conversion) check_fn = analysis_entry.get("check_applicable") if check_fn: check_result = check_fn(req.game) diff --git a/plugins/pycid/pycid_plugin/__main__.py b/plugins/pycid/pycid_plugin/__main__.py index 38e4f15..f2a4944 100644 --- a/plugins/pycid/pycid_plugin/__main__.py +++ b/plugins/pycid/pycid_plugin/__main__.py @@ -3,6 +3,7 @@ Run with: python -m pycid_plugin --port=PORT Implements the plugin HTTP contract (API v1). """ + from __future__ import annotations import argparse @@ -58,7 +59,10 @@ "applicable_to": ["maid"], "continuous": False, "config_schema": { - "profile": {"type": "object", "description": "Strategy profile: {agent: {decision: action}}"}, + "profile": { + "type": "object", + "description": "Strategy profile: {agent: {decision: action}}", + }, }, "run": run_verify_profile, }, @@ -131,13 +135,15 @@ def health() -> dict: def info() -> dict: analyses_info = [] for a in ANALYSES.values(): - analyses_info.append({ - "name": a["name"], - "description": a["description"], - "applicable_to": a["applicable_to"], - "continuous": a["continuous"], - "config_schema": a["config_schema"], - }) + analyses_info.append( + { + "name": a["name"], + "description": a["description"], + "applicable_to": a["applicable_to"], + "continuous": a["continuous"], + "config_schema": a["config_schema"], + } + ) return { "api_version": API_VERSION, "plugin_version": PLUGIN_VERSION, diff --git a/plugins/pycid/pycid_plugin/convert.py b/plugins/pycid/pycid_plugin/convert.py index 0400ef1..b3ab70b 100644 --- a/plugins/pycid/pycid_plugin/convert.py +++ b/plugins/pycid/pycid_plugin/convert.py @@ -5,6 +5,7 @@ creates a sequential representation where players move in order, with information sets encoding simultaneity. """ + from __future__ import annotations import uuid @@ -220,13 +221,17 @@ def compute_payoffs( eu_float = float(eu) # Check for NaN (can happen when utility CPDs are missing) if eu_float != eu_float: # NaN check - payoffs[agent] = _compute_utility_from_cpds(game, agent, strategy, decisions) + payoffs[agent] = _compute_utility_from_cpds( + game, agent, strategy, decisions + ) else: payoffs[agent] = eu_float except (RuntimeError, ValueError, TypeError): # Fall back to computing from CPDs if expected_utility fails # (e.g., when policies are required but not imputed) - payoffs[agent] = _compute_utility_from_cpds(game, agent, strategy, decisions) + payoffs[agent] = _compute_utility_from_cpds( + game, agent, strategy, decisions + ) return payoffs @@ -252,6 +257,7 @@ def _compute_utility_from_cpds( Expected utility value. """ import logging + logger = logging.getLogger(__name__) # Build lookup tables @@ -260,7 +266,8 @@ def _compute_utility_from_cpds( # Find utility nodes for this agent utility_nodes = [ - n for n in game.get("nodes", []) + n + for n in game.get("nodes", []) if n.get("type") == "utility" and n.get("agent") == agent ] @@ -274,7 +281,8 @@ def _compute_utility_from_cpds( logger.warning( "Utility nodes %s for agent %s have no CPDs defined - payoffs will be 0. " "Add CPDs to the MAID to specify payoff structure.", - missing_cpds, agent + missing_cpds, + agent, ) # Map decision nodes to their strategy indices @@ -286,7 +294,9 @@ def _compute_utility_from_cpds( try: dec_map[dec_node] = dec_domain.index(strategy[i]) except ValueError: - dec_map[dec_node] = int(strategy[i]) if strategy[i].lstrip("-").isdigit() else 0 + dec_map[dec_node] = ( + int(strategy[i]) if strategy[i].lstrip("-").isdigit() else 0 + ) total_utility = 0.0 @@ -344,7 +354,9 @@ def _compute_utility_from_cpds( for chance_node in chance_parents: chance_cpd = cpds_by_node.get(chance_node) chance_node_obj = nodes_by_id.get(chance_node) - chance_card = len(chance_node_obj.get("domain", [0, 1])) if chance_node_obj else 2 + chance_card = ( + len(chance_node_obj.get("domain", [0, 1])) if chance_node_obj else 2 + ) if chance_cpd and chance_cpd.get("values"): # Use the CPD values as probabilities (assume no parents for simplicity) @@ -354,13 +366,16 @@ def _compute_utility_from_cpds( chance_dists.append(probs[0]) else: # Column vector - flatten - chance_dists.append([row[0] if row else 1.0/chance_card for row in probs]) + chance_dists.append( + [row[0] if row else 1.0 / chance_card for row in probs] + ) else: # Uniform distribution chance_dists.append([1.0 / chance_card] * chance_card) # Enumerate all combinations of chance node values from itertools import product + chance_indices = [range(len(d)) for d in chance_dists] for chance_combo in product(*chance_indices): diff --git a/plugins/pycid/pycid_plugin/nash.py b/plugins/pycid/pycid_plugin/nash.py index c83aacb..f9e790a 100644 --- a/plugins/pycid/pycid_plugin/nash.py +++ b/plugins/pycid/pycid_plugin/nash.py @@ -1,4 +1,5 @@ """MAID Nash equilibrium analysis - standalone for plugin service.""" + from __future__ import annotations from typing import Any @@ -6,7 +7,9 @@ from pycid_plugin.pycid_utils import maid_game_to_pycid, format_ne_result -def run_maid_nash(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_maid_nash( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Compute pure-strategy Nash equilibria for a MAID. Note: PyCID only supports pure-strategy NE enumeration. diff --git a/plugins/pycid/pycid_plugin/pycid_utils.py b/plugins/pycid/pycid_plugin/pycid_utils.py index e4544ab..6a6a915 100644 --- a/plugins/pycid/pycid_plugin/pycid_utils.py +++ b/plugins/pycid/pycid_plugin/pycid_utils.py @@ -3,6 +3,7 @@ Moved from app/core/pycid_utils.py for plugin isolation. Operates on plain dicts (deserialized game JSON). """ + from __future__ import annotations from typing import Any @@ -126,7 +127,9 @@ def format_ne_result(ne_list: list, game: dict[str, Any]) -> list[dict]: agent = node.get("agent", "Unknown") if node else "Unknown" probs = cpd.get_values() - domain = list(cpd.domain) if hasattr(cpd, "domain") else list(range(len(probs))) + domain = ( + list(cpd.domain) if hasattr(cpd, "domain") else list(range(len(probs))) + ) strategy_probs = {} for j, action in enumerate(domain): @@ -144,10 +147,13 @@ def format_ne_result(ne_list: list, game: dict[str, Any]) -> list[dict]: is_pure = all(len(s) == 1 for s in strategies.values()) - formatted.append({ - "description": ("Pure: " if is_pure else "Mixed: ") + ", ".join(description_parts), - "strategies": strategies, - "behavior_profile": strategies, - }) + formatted.append( + { + "description": ("Pure: " if is_pure else "Mixed: ") + + ", ".join(description_parts), + "strategies": strategies, + "behavior_profile": strategies, + } + ) return formatted diff --git a/plugins/pycid/pycid_plugin/spe.py b/plugins/pycid/pycid_plugin/spe.py index f61975b..e54f7e1 100644 --- a/plugins/pycid/pycid_plugin/spe.py +++ b/plugins/pycid/pycid_plugin/spe.py @@ -1,4 +1,5 @@ """MAID Subgame Perfect Equilibrium analysis - standalone for plugin service.""" + from __future__ import annotations from typing import Any @@ -6,7 +7,9 @@ from pycid_plugin.pycid_utils import maid_game_to_pycid, format_ne_result -def run_maid_spe(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_maid_spe( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Compute Subgame Perfect Equilibria for a MAID. Subgame Perfect Equilibrium (SPE) is a refinement of Nash equilibrium diff --git a/plugins/pycid/pycid_plugin/verify_profile.py b/plugins/pycid/pycid_plugin/verify_profile.py index 8d56979..f46685f 100644 --- a/plugins/pycid/pycid_plugin/verify_profile.py +++ b/plugins/pycid/pycid_plugin/verify_profile.py @@ -1,4 +1,5 @@ """MAID Profile Verification - check if a strategy profile is a Nash equilibrium.""" + from __future__ import annotations from typing import Any @@ -6,7 +7,9 @@ from pycid_plugin.pycid_utils import maid_game_to_pycid -def run_verify_profile(game: dict[str, Any], config: dict[str, Any] | None = None) -> dict[str, Any]: +def run_verify_profile( + game: dict[str, Any], config: dict[str, Any] | None = None +) -> dict[str, Any]: """Verify if a given strategy profile is a Nash equilibrium for a MAID. Computes expected utility for each agent and checks for profitable @@ -26,7 +29,10 @@ def run_verify_profile(game: dict[str, Any], config: dict[str, Any] | None = Non if not profile: return { "summary": "Error: No profile provided", - "details": {"error": "Profile configuration required", "is_equilibrium": False}, + "details": { + "error": "Profile configuration required", + "is_equilibrium": False, + }, } try: @@ -81,20 +87,24 @@ def run_verify_profile(game: dict[str, Any], config: dict[str, Any] | None = Non alt_intervention[dec_node] = alt_action try: - alt_eu = macid.expected_utility(alt_intervention, agent=agent) + alt_eu = macid.expected_utility( + alt_intervention, agent=agent + ) alt_eu_float = float(alt_eu) if alt_eu_float > utilities[agent] + 1e-9: is_equilibrium = False - deviations.append({ - "agent": agent, - "decision": dec_node, - "current_action": current_action, - "better_action": alt_action, - "current_utility": utilities[agent], - "deviation_utility": alt_eu_float, - "improvement": alt_eu_float - utilities[agent], - }) + deviations.append( + { + "agent": agent, + "decision": dec_node, + "current_action": current_action, + "better_action": alt_action, + "current_utility": utilities[agent], + "deviation_utility": alt_eu_float, + "improvement": alt_eu_float - utilities[agent], + } + ) except Exception: pass diff --git a/plugins/pycid/tests/test_convert.py b/plugins/pycid/tests/test_convert.py index 16ce69b..cdf4523 100644 --- a/plugins/pycid/tests/test_convert.py +++ b/plugins/pycid/tests/test_convert.py @@ -17,7 +17,12 @@ def prisoners_dilemma_maid(): {"id": "D1", "type": "decision", "agent": "Row", "domain": ["C", "D"]}, {"id": "D2", "type": "decision", "agent": "Column", "domain": ["C", "D"]}, {"id": "U1", "type": "utility", "agent": "Row", "domain": [-3, -2, -1, 0]}, - {"id": "U2", "type": "utility", "agent": "Column", "domain": [-3, -2, -1, 0]}, + { + "id": "U2", + "type": "utility", + "agent": "Column", + "domain": [-3, -2, -1, 0], + }, ], "edges": [ {"source": "D1", "target": "U1"}, @@ -92,9 +97,9 @@ def test_convert_maid_to_efg_has_valid_tree_structure(prisoners_dilemma_maid): for action in node["actions"]: target = action.get("target") assert target is not None, f"Action in {node_id} has no target" - assert target in nodes or target in outcomes, ( - f"Target {target} not found in nodes or outcomes" - ) + assert ( + target in nodes or target in outcomes + ), f"Target {target} not found in nodes or outcomes" def test_convert_maid_to_efg_has_outcomes_with_payoffs(prisoners_dilemma_maid): @@ -112,7 +117,9 @@ def test_convert_maid_to_efg_has_outcomes_with_payoffs(prisoners_dilemma_maid): # Each player should have a payoff for player in players: - assert player in payoffs, f"Player {player} missing from payoffs in {outcome_id}" + assert ( + player in payoffs + ), f"Player {player} missing from payoffs in {outcome_id}" def test_convert_maid_to_efg_title(prisoners_dilemma_maid): @@ -157,6 +164,6 @@ def test_convert_maid_to_efg_includes_node_mapping(prisoners_dilemma_maid): nodes = result["nodes"] for maid_node_id, efg_node_ids in mapping.items(): for efg_node_id in efg_node_ids: - assert efg_node_id in nodes, ( - f"EFG node {efg_node_id} (mapped from MAID node {maid_node_id}) not in nodes" - ) + assert ( + efg_node_id in nodes + ), f"EFG node {efg_node_id} (mapped from MAID node {maid_node_id}) not in nodes" diff --git a/plugins/pycid/tests/test_nash.py b/plugins/pycid/tests/test_nash.py index 5bb76a9..9a3c99c 100644 --- a/plugins/pycid/tests/test_nash.py +++ b/plugins/pycid/tests/test_nash.py @@ -17,7 +17,12 @@ def prisoners_dilemma_maid(): {"id": "D1", "type": "decision", "agent": "Row", "domain": ["C", "D"]}, {"id": "D2", "type": "decision", "agent": "Column", "domain": ["C", "D"]}, {"id": "U1", "type": "utility", "agent": "Row", "domain": [-3, -2, -1, 0]}, - {"id": "U2", "type": "utility", "agent": "Column", "domain": [-3, -2, -1, 0]}, + { + "id": "U2", + "type": "utility", + "agent": "Column", + "domain": [-3, -2, -1, 0], + }, ], "edges": [ {"source": "D1", "target": "U1"}, diff --git a/plugins/pycid/tests/test_spe.py b/plugins/pycid/tests/test_spe.py index 5c6143c..810fa65 100644 --- a/plugins/pycid/tests/test_spe.py +++ b/plugins/pycid/tests/test_spe.py @@ -17,7 +17,12 @@ def prisoners_dilemma_maid(): {"id": "D1", "type": "decision", "agent": "Row", "domain": ["C", "D"]}, {"id": "D2", "type": "decision", "agent": "Column", "domain": ["C", "D"]}, {"id": "U1", "type": "utility", "agent": "Row", "domain": [-3, -2, -1, 0]}, - {"id": "U2", "type": "utility", "agent": "Column", "domain": [-3, -2, -1, 0]}, + { + "id": "U2", + "type": "utility", + "agent": "Column", + "domain": [-3, -2, -1, 0], + }, ], "edges": [ {"source": "D1", "target": "U1"}, diff --git a/plugins/vegas/tests/test_parser.py b/plugins/vegas/tests/test_parser.py index ca1e193..1ab8b38 100644 --- a/plugins/vegas/tests/test_parser.py +++ b/plugins/vegas/tests/test_parser.py @@ -2,10 +2,14 @@ import pytest -from vegas_plugin.parser import parse_vg, compile_to_maid, compile_to_target, COMPILE_TARGETS - - -PRISONERS_VG = ''' +from vegas_plugin.parser import ( + parse_vg, + compile_to_maid, + compile_to_target, + COMPILE_TARGETS, +) + +PRISONERS_VG = """ game main() { join A() $ 100; join B() $ 100; @@ -16,7 +20,7 @@ : (!A.c && B.c) ? { A -> 200; B -> 0 } : { A -> 90; B -> 110 } } -''' +""" def test_parse_vg_returns_vegas_game(): @@ -68,14 +72,14 @@ def test_compile_invalid_vg_raises_error(): def test_parse_simple_game(): """Test parsing a simple game.""" - simple_vg = ''' + simple_vg = """ game main() { join A() $ 10; join B() $ 10; yield or split A(x: bool) B(y: bool); withdraw { A -> 10; B -> 10 } } - ''' + """ game = parse_vg(simple_vg, "simple.vg") assert game["format_name"] == "vegas" @@ -125,7 +129,9 @@ def test_compile_to_scribble(): assert result["type"] == "code" assert result["language"] == "scribble" # Scribble defines protocols - assert "protocol" in result["content"].lower() or "role" in result["content"].lower() + assert ( + "protocol" in result["content"].lower() or "role" in result["content"].lower() + ) def test_compile_to_unknown_target_raises(): diff --git a/plugins/vegas/vegas_plugin/__main__.py b/plugins/vegas/vegas_plugin/__main__.py index ebb5316..1c71fd3 100644 --- a/plugins/vegas/vegas_plugin/__main__.py +++ b/plugins/vegas/vegas_plugin/__main__.py @@ -3,6 +3,7 @@ Run with: python -m vegas_plugin --port=PORT Implements the plugin HTTP contract (API v1). """ + from __future__ import annotations import argparse @@ -13,7 +14,12 @@ from fastapi import FastAPI, HTTPException from pydantic import BaseModel -from vegas_plugin.parser import parse_vg, compile_to_maid, compile_to_target, COMPILE_TARGETS +from vegas_plugin.parser import ( + parse_vg, + compile_to_maid, + compile_to_target, + COMPILE_TARGETS, +) logging.basicConfig( level=logging.INFO, diff --git a/plugins/vegas/vegas_plugin/parser.py b/plugins/vegas/vegas_plugin/parser.py index 03ec7f1..2dea631 100644 --- a/plugins/vegas/vegas_plugin/parser.py +++ b/plugins/vegas/vegas_plugin/parser.py @@ -1,4 +1,5 @@ """Parse and compile .vg files using the Vegas JAR.""" + from __future__ import annotations import json @@ -19,7 +20,7 @@ def _extract_title_from_source(content: str, filename: str) -> str: """Extract a title from Vegas source code or filename.""" # Try to find game name from 'game main()' or 'game GameName()' - match = re.search(r'game\s+(\w+)\s*\(', content) + match = re.search(r"game\s+(\w+)\s*\(", content) if match and match.group(1) != "main": return match.group(1) # Fall back to filename without extension @@ -32,7 +33,7 @@ def _extract_players_from_source(content: str) -> list[str]: Looks for 'join Player()' patterns. """ players = [] - for match in re.finditer(r'join\s+(\w+)\s*\(\s*\)', content): + for match in re.finditer(r"join\s+(\w+)\s*\(\s*\)", content): players.append(match.group(1)) return players @@ -106,7 +107,9 @@ def compile_to_maid(content: str, filename: str = "game.vg") -> dict[str, Any]: ) if result.returncode != 0: - error_msg = result.stderr.strip() or result.stdout.strip() or "Unknown error" + error_msg = ( + result.stderr.strip() or result.stdout.strip() or "Unknown error" + ) logger.error("Vegas compilation failed: %s", error_msg) raise ValueError(f"Vegas compilation failed: {error_msg}") @@ -161,7 +164,9 @@ def compile_to_maid(content: str, filename: str = "game.vg") -> dict[str, Any]: } -def compile_to_target(content: str, target: str, filename: str = "game.vg") -> dict[str, Any]: +def compile_to_target( + content: str, target: str, filename: str = "game.vg" +) -> dict[str, Any]: """Compile a .vg file to a specific target format. Args: @@ -177,7 +182,9 @@ def compile_to_target(content: str, target: str, filename: str = "game.vg") -> d FileNotFoundError: If Vegas JAR is not found """ if target not in COMPILE_TARGETS: - raise ValueError(f"Unknown compile target: {target}. Available: {list(COMPILE_TARGETS.keys())}") + raise ValueError( + f"Unknown compile target: {target}. Available: {list(COMPILE_TARGETS.keys())}" + ) target_info = COMPILE_TARGETS[target] @@ -204,7 +211,9 @@ def compile_to_target(content: str, target: str, filename: str = "game.vg") -> d ) if result.returncode != 0: - error_msg = result.stderr.strip() or result.stdout.strip() or "Unknown error" + error_msg = ( + result.stderr.strip() or result.stdout.strip() or "Unknown error" + ) logger.error("Vegas compilation failed: %s", error_msg) raise ValueError(f"Vegas compilation failed: {error_msg}") @@ -213,7 +222,9 @@ def compile_to_target(content: str, target: str, filename: str = "game.vg") -> d output_path = Path(tmpdir) / f"{base_name}{target_info['extension']}" if not output_path.exists(): - raise ValueError(f"Vegas did not produce {target} output. Check the Vegas log.") + raise ValueError( + f"Vegas did not produce {target} output. Check the Vegas log." + ) output_content = output_path.read_text(encoding="utf-8") diff --git a/tests/test_remote_plugin.py b/tests/test_remote_plugin.py index 5027f2a..8865039 100644 --- a/tests/test_remote_plugin.py +++ b/tests/test_remote_plugin.py @@ -69,11 +69,16 @@ class TestRunUnreachable: def test_unreachable_plugin(self, remote_plugin): """When plugin is not running, run() should return an error result.""" game = MagicMock() + game.format_name = "extensive" # Must be set on object for getattr check game.model_dump.return_value = {"id": "test", "format_name": "extensive"} - result = remote_plugin.run(game) - assert isinstance(result, AnalysisResult) - assert "unreachable" in result.summary.lower() or "error" in result.summary.lower() + # Mock export_to_efg since test uses minimal game dict + with patch("app.core.remote_plugin.export_to_efg") as mock_efg: + mock_efg.return_value = "EFG 2 R \"Test\" { \"P1\" \"P2\" }" + + result = remote_plugin.run(game) + assert isinstance(result, AnalysisResult) + assert "unreachable" in result.summary.lower() or "error" in result.summary.lower() class TestSummarize: @@ -86,12 +91,16 @@ class TestRunWithCancellation: def test_cancel_before_poll(self, remote_plugin): """If cancel_event is set, run should return cancelled result.""" game = MagicMock() + game.format_name = "extensive" # Must be set on object, not just in model_dump game.model_dump.return_value = {"id": "test", "format_name": "extensive"} cancel_event = threading.Event() - # Mock httpx in http_client module (where RemoteServiceClient uses it) - with patch("app.core.http_client.httpx") as mock_httpx: + # Mock httpx in http_client module and export_to_efg + with patch("app.core.http_client.httpx") as mock_httpx, \ + patch("app.core.remote_plugin.export_to_efg") as mock_efg: + mock_efg.return_value = "EFG 2 R \"Test\" { \"P1\" \"P2\" }" + mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"task_id": "p-abc", "status": "running"} From da8ea24487fa778b290e54752c9c25d1401e3e49 Mon Sep 17 00:00:00 2001 From: Elazar Gershuni Date: Sun, 1 Feb 2026 02:56:00 +0200 Subject: [PATCH 4/5] handle conversions correctly --- app/conversions/__init__.py | 1 - .../shared => app/conversions}/efg_export.py | 23 ++- app/conversions/efg_string.py | 172 ------------------ app/core/remote_plugin.py | 36 +--- app/core/store.py | 2 +- app/main.py | 37 +--- app/models/__init__.py | 4 +- app/models/efg_string.py | 24 --- app/models/extensive_form.py | 17 +- docker/Dockerfile.vegas | 2 +- plugins/egttools/egttools_plugin/__main__.py | 14 +- plugins/gambit/gambit_plugin/parsers.py | 7 +- plugins/gambit/tests/test_parsers.py | 2 +- shared-pkg/shared/__init__.py | 3 +- tests/test_remote_plugin.py | 22 ++- 15 files changed, 74 insertions(+), 292 deletions(-) rename {shared-pkg/shared => app/conversions}/efg_export.py (83%) delete mode 100644 app/conversions/efg_string.py delete mode 100644 app/models/efg_string.py diff --git a/app/conversions/__init__.py b/app/conversions/__init__.py index df314de..5b3e6fb 100644 --- a/app/conversions/__init__.py +++ b/app/conversions/__init__.py @@ -11,7 +11,6 @@ # Import converters for registration side effects from app.conversions import efg_nfg as _efg_nfg # noqa: F401 -from app.conversions import efg_string as _efg_string # noqa: F401 __all__ = [ "Conversion", diff --git a/shared-pkg/shared/efg_export.py b/app/conversions/efg_export.py similarity index 83% rename from shared-pkg/shared/efg_export.py rename to app/conversions/efg_export.py index 0668ece..cfd7030 100644 --- a/shared-pkg/shared/efg_export.py +++ b/app/conversions/efg_export.py @@ -1,4 +1,9 @@ -"""Export extensive-form game dicts to Gambit EFG format.""" +"""Export extensive-form game dicts to Gambit EFG format. + +This is a utility function, not a format conversion. It's used by conversions +and parsers that produce ExtensiveFormGame to populate the efg_content field. +""" + from __future__ import annotations from typing import Any @@ -81,14 +86,18 @@ def traverse(node_id: str) -> list[str]: if node is None: # Missing node - create dummy terminal outcome_num = get_outcome_number(f"missing_{node_id}") - result.append(f't "" {outcome_num} "missing_{node_id}" {{ {", ".join("0" for _ in players)} }}') + result.append( + f't "" {outcome_num} "missing_{node_id}" {{ {", ".join("0" for _ in players)} }}' + ) return result player_name = node.get("player", "") player = player_idx.get(player_name, 1) infoset = get_infoset_number(player, node.get("information_set")) actions = node.get("actions", []) - action_labels = " ".join(f'"{a.get("label", "?").replace(chr(34), chr(39))}"' for a in actions) + action_labels = " ".join( + f'"{a.get("label", "?").replace(chr(34), chr(39))}"' for a in actions + ) # Personal node: p "label" player infoset { actions } 0 label = node.get("id", node_id).replace('"', "'") @@ -101,8 +110,12 @@ def traverse(node_id: str) -> list[str]: result.extend(traverse(target)) else: # No target - create dummy terminal - outcome_num = get_outcome_number(f"none_{node_id}_{action.get('label', '')}") - result.append(f't "" {outcome_num} "none" {{ {", ".join("0" for _ in players)} }}') + outcome_num = get_outcome_number( + f"none_{node_id}_{action.get('label', '')}" + ) + result.append( + f't "" {outcome_num} "none" {{ {", ".join("0" for _ in players)} }}' + ) return result diff --git a/app/conversions/efg_string.py b/app/conversions/efg_string.py deleted file mode 100644 index ebe09d2..0000000 --- a/app/conversions/efg_string.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Conversion from ExtensiveFormGame to Gambit EFG text format. - -EFG is the standard format used by Gambit, OpenSpiel, and other game theory tools. -""" - -from __future__ import annotations - -from typing import Any, TYPE_CHECKING - -from app.conversions.registry import Conversion, ConversionCheck -from app.models.efg_string import EfgStringGame - -if TYPE_CHECKING: - from app.models.extensive_form import ExtensiveFormGame - - -def _export_to_efg(game: dict[str, Any]) -> str: - """Convert an ExtensiveFormGame dict to Gambit EFG text format. - - EFG format reference: https://gambitproject.readthedocs.io/en/latest/formats.html - - Args: - game: Dict with keys: players, title, root, nodes, outcomes. - nodes[id] = {id, player, actions: [{label, target}], information_set?} - outcomes[id] = {label, payoffs: {player: value}} - - Returns: - EFG format string that can be parsed by Gambit/OpenSpiel. - """ - lines = [] - - players = game.get("players", []) - nodes = game.get("nodes", {}) - outcomes = game.get("outcomes", {}) - root = game.get("root", "") - title = game.get("title", "Game").replace('"', "'") - - # Header: EFG 2 R "Title" { "Player1" "Player2" ... } - player_list = " ".join(f'"{p}"' for p in players) - lines.append(f'EFG 2 R "{title}" {{ {player_list} }}') - lines.append("") - - # Build player index (1-based for Gambit) - player_idx = {name: i + 1 for i, name in enumerate(players)} - - # Track information sets per player - infoset_counter: dict[int, int] = {i + 1: 0 for i in range(len(players))} - infoset_map: dict[str, int] = {} - - # Track outcome numbers (1-based) - outcome_counter = [0] # Use list for mutability in nested function - outcome_number_map: dict[str, int] = {} - - def get_infoset_number(player: int, infoset_name: str | None) -> int: - """Get or create information set number for a player.""" - if infoset_name is None: - # Singleton information set - create unique one - infoset_counter[player] += 1 - return infoset_counter[player] - - key = f"{player}:{infoset_name}" - if key not in infoset_map: - infoset_counter[player] += 1 - infoset_map[key] = infoset_counter[player] - return infoset_map[key] - - def get_outcome_number(outcome_id: str) -> int: - """Get or create outcome number for a terminal node.""" - if outcome_id not in outcome_number_map: - outcome_counter[0] += 1 - outcome_number_map[outcome_id] = outcome_counter[0] - return outcome_number_map[outcome_id] - - def traverse(node_id: str) -> list[str]: - """Recursively traverse and generate EFG lines.""" - result = [] - - # Check if this is an outcome (terminal) - if node_id in outcomes: - outcome = outcomes[node_id] - # Terminal node: t "label" outcome_number { payoffs } - payoff_dict = outcome.get("payoffs", {}) - payoffs = ", ".join(str(payoff_dict.get(p, 0)) for p in players) - label = outcome.get("label", node_id).replace('"', "'") - outcome_num = get_outcome_number(node_id) - result.append(f't "{label}" {outcome_num} "{label}" {{ {payoffs} }}') - return result - - # Decision node - node = nodes.get(node_id) - if node is None: - # Missing node - create dummy terminal - outcome_num = get_outcome_number(f"missing_{node_id}") - result.append( - f't "" {outcome_num} "missing_{node_id}" {{ {", ".join("0" for _ in players)} }}' - ) - return result - - player_name = node.get("player", "") - player = player_idx.get(player_name, 1) - infoset = get_infoset_number(player, node.get("information_set")) - actions = node.get("actions", []) - action_labels = " ".join( - f'"{a.get("label", "?").replace(chr(34), chr(39))}"' for a in actions - ) - - # Personal node: p "label" player infoset { actions } 0 - label = node.get("id", node_id).replace('"', "'") - result.append(f'p "{label}" {player} {infoset} {{ {action_labels} }} 0') - - # Recursively add children - for action in actions: - target = action.get("target") - if target: - result.extend(traverse(target)) - else: - # No target - create dummy terminal - outcome_num = get_outcome_number( - f"none_{node_id}_{action.get('label', '')}" - ) - result.append( - f't "" {outcome_num} "none" {{ {", ".join("0" for _ in players)} }}' - ) - - return result - - lines.extend(traverse(root)) - - return "\n".join(lines) - - -def _can_convert_to_efg(game: "ExtensiveFormGame") -> ConversionCheck: - """Check if an extensive-form game can be converted to EFG.""" - # All extensive-form games can be converted to EFG - return ConversionCheck(possible=True) - - -def _convert_extensive_to_efg(game: "ExtensiveFormGame") -> EfgStringGame: - """Convert ExtensiveFormGame to EfgStringGame (Gambit EFG format).""" - game_dict = game.model_dump() - efg_content = _export_to_efg(game_dict) - return EfgStringGame( - id=game.id, - title=game.title, - players=list(game.players), - efg_content=efg_content, - tags=list(game.tags), - ) - - -# ============================================================================= -# Registration -# ============================================================================= - - -def _register_conversions() -> None: - """Register extensive -> EFG conversion.""" - from app.dependencies import get_conversion_registry - - registry = get_conversion_registry() - registry.register( - Conversion( - name="extensive to efg", - source_format="extensive", - target_format="efg", - can_convert=_can_convert_to_efg, - convert=_convert_extensive_to_efg, - ) - ) - - -_register_conversions() diff --git a/app/core/remote_plugin.py b/app/core/remote_plugin.py index f754020..eb273a2 100644 --- a/app/core/remote_plugin.py +++ b/app/core/remote_plugin.py @@ -8,7 +8,6 @@ from app.config import RemotePluginConfig from app.core.http_client import RemoteServiceClient, RemoteServiceError from app.core.registry import AnalysisResult -from shared import export_to_efg logger = logging.getLogger(__name__) @@ -51,39 +50,16 @@ def can_run(self, game) -> bool: return False def _prepare_game_data(self, game) -> tuple[dict | None, AnalysisResult | None]: - """Convert game to required format. Returns (game_data, error_result).""" - from app.dependencies import get_conversion_registry + """Get game in required format from store, serialize to dict.""" + from app.dependencies import get_game_store - native_format = getattr(game, "format_name", None) - conversion_registry = get_conversion_registry() + store = get_game_store() # Find a format we can provide for target_format in self.applicable_to: - if native_format == target_format: - game_data = game.model_dump() - else: - check = conversion_registry.check(game, target_format, quick=True) - if not check.possible: - continue - try: - converted = conversion_registry.convert(game, target_format) - game_data = converted.model_dump() - except ValueError as e: - continue # Try next format - - # Add EFG content for extensive-form games - if target_format == "extensive": - try: - game_data["efg_content"] = export_to_efg(game_data) - except ValueError as e: - return None, AnalysisResult( - summary=f"Error: EFG export failed: {e}", - details={ - "error": {"code": "EFG_EXPORT_FAILED", "message": str(e)} - }, - ) - - return game_data, None + converted = store.get_converted(game.id, target_format) + if converted: + return converted.model_dump(), None # No format worked return None, AnalysisResult( diff --git a/app/core/store.py b/app/core/store.py index e6a1b38..57c947f 100644 --- a/app/core/store.py +++ b/app/core/store.py @@ -19,7 +19,7 @@ def is_supported_format(format_name: str) -> bool: """Check if the format name is supported.""" - return format_name in ("extensive", "normal", "maid", "vegas", "efg") + return format_name in ("extensive", "normal", "maid", "vegas") class ConversionInfo(BaseModel): diff --git a/app/main.py b/app/main.py index f6a0cac..eec2593 100644 --- a/app/main.py +++ b/app/main.py @@ -21,8 +21,6 @@ register_healthy_plugins, ) -from shared import export_to_efg - # Configure logging logging.basicConfig( level=logging.INFO, @@ -183,45 +181,26 @@ def check_applicable(game_id: str) -> dict: return {"error": f"Game not found: {game_id}"} conversion_registry = get_conversion_registry() - native_format = getattr(game, "format_name", None) results: dict[str, dict] = {} - # Cache converted games to avoid redundant conversions + # Cache converted game dicts to avoid redundant serialization converted_games: dict[str, dict] = {} def get_game_in_format(target_format: str) -> tuple[dict | None, str | None]: - """Get game data in target format, with caching. Returns (game_data, error).""" + """Get game data in target format, from store cache. Returns (game_data, error).""" if target_format in converted_games: return converted_games[target_format], None - if native_format == target_format: - game_data = game.model_dump() - # Add EFG content for extensive-form games - if target_format == "extensive": - try: - game_data["efg_content"] = export_to_efg(game_data) - except ValueError as e: - return None, f"EFG export failed: {e}" + converted = store.get_converted(game_id, target_format) + if converted: + game_data = converted.model_dump() converted_games[target_format] = game_data return game_data, None - # Try conversion + # Check why conversion failed check = conversion_registry.check(game, target_format, quick=True) - if not check.possible: - reason = ( - ", ".join(check.blockers) if check.blockers else "no conversion path" - ) - return None, f"Cannot convert to {target_format} format: {reason}" - - try: - converted = conversion_registry.convert(game, target_format) - game_data = converted.model_dump() - if target_format == "extensive": - game_data["efg_content"] = export_to_efg(game_data) - converted_games[target_format] = game_data - return game_data, None - except ValueError as e: - return None, f"Conversion failed: {e}" + reason = ", ".join(check.blockers) if check.blockers else "no conversion path" + return None, f"Cannot convert to {target_format}: {reason}" for name, pp in plugin_manager.plugins.items(): if not pp.healthy or not pp.url: diff --git a/app/models/__init__.py b/app/models/__init__.py index d7f1613..87a9216 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,16 +6,14 @@ from app.models.normal_form import NormalFormGame from app.models.maid import MAIDEdge, MAIDGame, MAIDNode, TabularCPD from app.models.vegas import VegasGame -from app.models.efg_string import EfgStringGame # Type alias for any game type - used across plugins and converters -AnyGame = Union[ExtensiveFormGame, NormalFormGame, MAIDGame, VegasGame, EfgStringGame] +AnyGame = Union[ExtensiveFormGame, NormalFormGame, MAIDGame, VegasGame] __all__ = [ "Action", "AnyGame", "DecisionNode", - "EfgStringGame", "ExtensiveFormGame", "MAIDEdge", "MAIDGame", diff --git a/app/models/efg_string.py b/app/models/efg_string.py deleted file mode 100644 index d52217d..0000000 --- a/app/models/efg_string.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Gambit EFG text format model.""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field - - -class EfgStringGame(BaseModel): - """Game in Gambit's standard EFG text format. - - This is the industry-standard format used by Gambit, OpenSpiel, and other - game theory tools. The efg_content field contains the actual EFG string. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - id: str - title: str - players: list[str] - efg_content: str = Field(description="Gambit EFG text format string") - tags: list[str] = Field(default_factory=list) - format_name: Literal["efg"] = "efg" diff --git a/app/models/extensive_form.py b/app/models/extensive_form.py index 2556f34..6a5f215 100644 --- a/app/models/extensive_form.py +++ b/app/models/extensive_form.py @@ -1,7 +1,7 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class Outcome(BaseModel): @@ -56,6 +56,19 @@ class ExtensiveFormGame(BaseModel): format_name: Literal["extensive"] = "extensive" # Mapping from MAID decision node IDs to EFG node IDs (present when converted from MAID) maid_to_efg_nodes: dict[str, list[str]] | None = None + # Gambit EFG text format (for OpenSpiel and other tools) - auto-computed if not provided + efg_content: str | None = None + + @model_validator(mode="before") + @classmethod + def _compute_efg_content(cls, data: Any) -> Any: + """Compute efg_content if not provided.""" + if isinstance(data, dict) and not data.get("efg_content"): + from app.conversions.efg_export import export_to_efg + + data = dict(data) # Make a copy to avoid mutating input + data["efg_content"] = export_to_efg(data) + return data def reachable_outcomes(self) -> list[Outcome]: """Return the list of outcomes reachable from the root. diff --git a/docker/Dockerfile.vegas b/docker/Dockerfile.vegas index 981d560..b293e95 100644 --- a/docker/Dockerfile.vegas +++ b/docker/Dockerfile.vegas @@ -7,7 +7,7 @@ WORKDIR /app/plugins/vegas # Install Java runtime for Vegas JAR execution RUN apt-get update && apt-get install -y --no-install-recommends \ - default-jre-headless \ + openjdk-25-jre-headless \ && rm -rf /var/lib/apt/lists/* # Copy Vegas JAR diff --git a/plugins/egttools/egttools_plugin/__main__.py b/plugins/egttools/egttools_plugin/__main__.py index a768699..d331116 100644 --- a/plugins/egttools/egttools_plugin/__main__.py +++ b/plugins/egttools/egttools_plugin/__main__.py @@ -170,22 +170,14 @@ class CheckApplicableRequest(BaseModel): @app.post("/check-applicable") def check_applicable(req: CheckApplicableRequest) -> dict: - """Check which analyses are applicable to the given game. + """Check game-specific constraints for each analysis. - Returns a dict mapping analysis name to {applicable: bool, reason?: str}. + The orchestrator already verified format compatibility and conversion. + This endpoint only checks game-specific constraints. """ results = {} for name, analysis in ANALYSES.items(): - # Check format compatibility - game_format = req.game.get("format_name", "") - if game_format not in analysis["applicable_to"]: - results[name] = { - "applicable": False, - "reason": f"Requires {' or '.join(analysis['applicable_to'])} format", - } - continue - # All EGTTools analyses require symmetric games (square payoff matrix) payoffs = req.game.get("payoffs", []) if payoffs: diff --git a/plugins/gambit/gambit_plugin/parsers.py b/plugins/gambit/gambit_plugin/parsers.py index bcb66be..2690067 100644 --- a/plugins/gambit/gambit_plugin/parsers.py +++ b/plugins/gambit/gambit_plugin/parsers.py @@ -16,7 +16,10 @@ def parse_efg(content: str, filename: str = "game.efg") -> dict[str, Any]: """Parse Gambit EFG format into dict matching ExtensiveFormGame schema.""" gambit_game = gbt.read_efg(io.StringIO(content)) - return _gambit_efg_to_dict(gambit_game, source_file=filename) + result = _gambit_efg_to_dict(gambit_game, source_file=filename) + # Include the original EFG content for OpenSpiel and other tools + result["efg_content"] = content + return result def parse_nfg(content: str, filename: str = "game.nfg") -> dict[str, Any]: @@ -76,7 +79,7 @@ def _gambit_efg_to_dict(gambit_game: gbt.Game, source_file: str = "") -> dict[st "root": root_id, "nodes": nodes, "outcomes": outcomes, - "tags": ["imported", "efg"], + "tags": ["imported", "extensive"], } diff --git a/plugins/gambit/tests/test_parsers.py b/plugins/gambit/tests/test_parsers.py index 8338a61..a2658a1 100644 --- a/plugins/gambit/tests/test_parsers.py +++ b/plugins/gambit/tests/test_parsers.py @@ -98,7 +98,7 @@ def test_outcomes_have_payoffs_for_all_players(self): def test_imported_games_have_efg_tag(self): game = parse_efg(SIMPLE_GAME_EFG, "simple.efg") - assert "efg" in game["tags"] + assert "extensive" in game["tags"] assert "imported" in game["tags"] def test_parse_invalid_efg_raises_error(self): diff --git a/shared-pkg/shared/__init__.py b/shared-pkg/shared/__init__.py index 8f279a5..702ad65 100644 --- a/shared-pkg/shared/__init__.py +++ b/shared-pkg/shared/__init__.py @@ -7,6 +7,5 @@ The utilities operate on plain dicts to maximize compatibility. The core app provides wrapper functions that convert Pydantic models to dicts. """ -from shared.efg_export import export_to_efg -__all__ = ["export_to_efg"] +__all__: list[str] = [] diff --git a/tests/test_remote_plugin.py b/tests/test_remote_plugin.py index 8865039..b544423 100644 --- a/tests/test_remote_plugin.py +++ b/tests/test_remote_plugin.py @@ -69,13 +69,15 @@ class TestRunUnreachable: def test_unreachable_plugin(self, remote_plugin): """When plugin is not running, run() should return an error result.""" game = MagicMock() - game.format_name = "extensive" # Must be set on object for getattr check + game.id = "test-game" + game.format_name = "extensive" game.model_dump.return_value = {"id": "test", "format_name": "extensive"} - # Mock export_to_efg since test uses minimal game dict - with patch("app.core.remote_plugin.export_to_efg") as mock_efg: - mock_efg.return_value = "EFG 2 R \"Test\" { \"P1\" \"P2\" }" + # Mock the store to return the game + mock_store = MagicMock() + mock_store.get_converted.return_value = game + with patch("app.dependencies.get_game_store", return_value=mock_store): result = remote_plugin.run(game) assert isinstance(result, AnalysisResult) assert "unreachable" in result.summary.lower() or "error" in result.summary.lower() @@ -91,15 +93,19 @@ class TestRunWithCancellation: def test_cancel_before_poll(self, remote_plugin): """If cancel_event is set, run should return cancelled result.""" game = MagicMock() - game.format_name = "extensive" # Must be set on object, not just in model_dump + game.id = "test-game" + game.format_name = "extensive" game.model_dump.return_value = {"id": "test", "format_name": "extensive"} cancel_event = threading.Event() - # Mock httpx in http_client module and export_to_efg + # Mock the store to return the game + mock_store = MagicMock() + mock_store.get_converted.return_value = game + + # Mock httpx in http_client module with patch("app.core.http_client.httpx") as mock_httpx, \ - patch("app.core.remote_plugin.export_to_efg") as mock_efg: - mock_efg.return_value = "EFG 2 R \"Test\" { \"P1\" \"P2\" }" + patch("app.dependencies.get_game_store", return_value=mock_store): mock_response = MagicMock() mock_response.status_code = 200 From 88d0173a02710e3bfef43e92df3bf53037b13b40 Mon Sep 17 00:00:00 2001 From: Elazar Gershuni Date: Sun, 1 Feb 2026 03:03:42 +0200 Subject: [PATCH 5/5] update efg_content hackily --- app/models/extensive_form.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/extensive_form.py b/app/models/extensive_form.py index 6a5f215..9ae8789 100644 --- a/app/models/extensive_form.py +++ b/app/models/extensive_form.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Any, Literal +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -59,16 +59,16 @@ class ExtensiveFormGame(BaseModel): # Gambit EFG text format (for OpenSpiel and other tools) - auto-computed if not provided efg_content: str | None = None - @model_validator(mode="before") - @classmethod - def _compute_efg_content(cls, data: Any) -> Any: + @model_validator(mode="after") + def _compute_efg_content(self) -> "ExtensiveFormGame": """Compute efg_content if not provided.""" - if isinstance(data, dict) and not data.get("efg_content"): + if not self.efg_content: from app.conversions.efg_export import export_to_efg - data = dict(data) # Make a copy to avoid mutating input - data["efg_content"] = export_to_efg(data) - return data + efg_content = export_to_efg(self.model_dump()) + # Use object.__setattr__ since model is frozen + object.__setattr__(self, "efg_content", efg_content) + return self def reachable_outcomes(self) -> list[Outcome]: """Return the list of outcomes reachable from the root.