diff --git a/ChangeLog b/ChangeLog index a50e9f4121..879cf50d20 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,9 @@ ## [16.7.0] - unreleased +### Added +- Games can be materialised directly from OpenSpiel games if `pyspiel` is installed. (#917) + ### Fixed - Corrected resizing of row and column index labels in strategic form so pivoting works correctly. (#844) - Corrected incorrect output of strategic game tables to .nfg files if strategies have previously been diff --git a/doc/catalog.rst b/doc/catalog.rst index 2c90df4c88..dfb0875e23 100644 --- a/doc/catalog.rst +++ b/doc/catalog.rst @@ -5,5 +5,29 @@ Catalog of games Below is a complete list of games included in Gambit's catalog. Check out the :ref:`pygambit API reference ` for instructions on how to search and load these games in Python, and the :ref:`Updating the games catalog ` guide for instructions on how to contribute new games to the catalog. +Games from the OpenSpiel library are also available; see :ref:`Loading OpenSpiel games `. .. include:: catalog_table.rst + +.. _catalog-openspiel: + +.. rubric:: Loading OpenSpiel games + +Games from the `OpenSpiel `_ library +can be loaded using :func:`pygambit.catalog.load_openspiel`: + +.. code-block:: python + + pygambit.catalog.load_openspiel("matrix_rps") + pygambit.catalog.load_openspiel("tiny_hanabi") + pygambit.catalog.load_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2}) + +The ``params`` argument is forwarded directly to ``pyspiel.load_game``; see the +`OpenSpiel game list `_ for +available parameters per game. + +This requires ``open_spiel`` to be installed (``pip install open_spiel``; +not available on Windows). The game is exported to EFG or NFG format on the fly +and loaded into Gambit. Not all OpenSpiel games can be exported; a +:class:`ValueError` is raised for games that are incompatible with either format. +See the :doc:`OpenSpiel interoperability tutorial ` for worked examples. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 7f8eea6040..5841f02bee 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -338,4 +338,5 @@ Catalog of games :toctree: api/ load + load_openspiel games diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index fc8fe8be7f..74ec53ae6e 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -30,14 +30,11 @@ "metadata": {}, "outputs": [], "source": [ - "from io import StringIO\n", - "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import pyspiel\n", "from open_spiel.python import rl_environment\n", "from open_spiel.python.algorithms import tabular_qlearner\n", - "from open_spiel.python.algorithms.gambit import export_gambit\n", "from open_spiel.python.egt import dynamics\n", "from open_spiel.python.egt.utils import game_payoffs_array\n", "\n", @@ -150,27 +147,7 @@ "id": "045cf8dd", "metadata": {}, "source": [ - "OpenSpiel can generate an NFG representation of the game loadable in Gambit:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f5fa4e42", - "metadata": {}, - "outputs": [], - "source": [ - "nfg_matrix_rps_game = pyspiel.game_to_nfg_string(ops_matrix_rps_game)\n", - "nfg_matrix_rps_game" - ] - }, - { - "cell_type": "markdown", - "id": "70d1df64", - "metadata": {}, - "source": [ - "Now let's load the NFG in Gambit. Since Gambit's `read_nfg` function expects a file like object, we'll convert the string with `io.StringIO`.\n", - "We can also add labels for the actions to make the output more interpretable:" + "Gambit's catalog module can load games from the OpenSpiel library using `gbt.catalog.load_openspiel`:\n" ] }, { @@ -180,7 +157,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_matrix_rps_game = gbt.read_nfg(StringIO(nfg_matrix_rps_game))\n", + "gbt_matrix_rps_game = gbt.catalog.load_openspiel(\"matrix_rps\")\n", "\n", "gbt_matrix_rps_game.title = \"Rock-Paper-Scissors\"\n", "\n", @@ -462,19 +439,8 @@ "source": [ "## Extensive-form games from the OpenSpiel library\n", "\n", - "For extensive-form games, OpenSpiel can export to the EFG format used by Gambit. Here we demonstrate this with **Tiny Hanabi**, loaded from the OpenSpiel [game library](https://openspiel.readthedocs.io/en/latest/games.html):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02a42600", - "metadata": {}, - "outputs": [], - "source": [ - "ops_hanabi_game = pyspiel.load_game(\"tiny_hanabi\")\n", - "efg_hanabi_game = export_gambit(ops_hanabi_game)\n", - "efg_hanabi_game" + "We can also load extensive-form games via Gambit's catalog module.\n", + "Here we demonstrate this with **Tiny Hanabi**, from the OpenSpiel [game library](https://openspiel.readthedocs.io/en/latest/games.html):\n" ] }, { @@ -482,7 +448,6 @@ "id": "fa354c9f", "metadata": {}, "source": [ - "Now let's load the EFG in Gambit.\n", "We can then compute equilibria strategies for the players as usual:" ] }, @@ -493,7 +458,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_hanabi_game = gbt.read_efg(StringIO(efg_hanabi_game))\n", + "gbt_hanabi_game = gbt.catalog.load_openspiel(\"tiny_hanabi\")\n", "eqm = gbt.nash.lcp_solve(gbt_hanabi_game).equilibria[0]" ] }, @@ -880,7 +845,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.5" + "version": "3.13.13" } }, "nbformat": 4, diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 961c2a532d..2b6aadaebb 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -1,3 +1,4 @@ +import io from importlib.resources import as_file, files from pathlib import Path from typing import Any @@ -20,6 +21,80 @@ } +def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: + """ + Load a game from the OpenSpiel library. + + Parameters + ---------- + game_name : str + The short name of the OpenSpiel game (e.g. ``"matrix_rps"``, + ``"tiny_hanabi"``). Passed directly to ``pyspiel.load_game``. + params : dict, optional + Game parameters forwarded to ``pyspiel.load_game`` + (e.g. ``{"players": 2, "coins": 3, "fields": 2}`` for ``"blotto"``). + See the `OpenSpiel game list + `_ for + available parameters per game. + + Returns + ------- + gbt.Game + The loaded game. + + Raises + ------ + ImportError + If ``open_spiel`` is not installed. + ValueError + If the game's dynamics type is not supported for export, or if the + format exporter raises an error for this specific game. + Other exceptions from ``pyspiel.load_game`` propagate directly. + For example, ``pyspiel.SpielError`` is raised for unknown game names + or invalid/missing parameters. + """ + try: + import pyspiel + from open_spiel.python.algorithms.gambit import export_gambit + except ImportError as exc: + raise ImportError( + "open_spiel is required to load OpenSpiel games. " + "Install it with: pip install open_spiel" + ) from exc + + # Let pyspiel's own exceptions propagate unchanged — they already carry + # informative messages ("Unknown game '...'", "Unknown parameter '...'", etc.) + game = pyspiel.load_game(game_name, params or {}) + + dynamics = game.get_type().dynamics + + # OpenSpiel's SEQUENTIAL corresponds to extensive-form (tree) games in Gambit; + # SIMULTANEOUS corresponds to normal-form (strategic-form) games. + if dynamics == pyspiel.GameType.Dynamics.SEQUENTIAL: + try: + efg_str = export_gambit(game) + except Exception as exc: + raise ValueError( + f"OpenSpiel game '{game_name}' could not be exported to EFG format: {exc}" + ) from exc + return gbt.read_efg(io.StringIO(efg_str)) + + elif dynamics == pyspiel.GameType.Dynamics.SIMULTANEOUS: + try: + nfg_str = pyspiel.game_to_nfg_string(game) + except Exception as exc: + raise ValueError( + f"OpenSpiel game '{game_name}' could not be exported to NFG format: {exc}" + ) from exc + return gbt.read_nfg(io.StringIO(nfg_str)) + + else: + raise ValueError( + f"OpenSpiel game '{game_name}' has unsupported dynamics type " + f"'{dynamics}' and cannot be exported to Gambit format." + ) + + def load(slug: str) -> gbt.Game: """ Load a game from the package catalog. diff --git a/tests/test_catalog.py b/tests/test_catalog.py index c034a3cbdc..f019d4a019 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -1,3 +1,6 @@ +import sys +from unittest.mock import MagicMock + import pandas as pd import pytest @@ -172,3 +175,135 @@ def test_catalog_games_include_descriptions(): games_with_desc = gbt.catalog.games(include_descriptions=True) assert "Description" in games_with_desc.columns assert "Download" in games_with_desc.columns + + +# --------------------------------------------------------------------------- +# OpenSpiel dynamic loading tests (all mocked; open_spiel need not be installed) +# --------------------------------------------------------------------------- + +_MOCK_NFG = gbt.Game.new_table([2, 2]).to_nfg() +_MOCK_EFG = gbt.catalog.load("bagwell1995").to_efg() + + +def _setup_pyspiel_mock( + monkeypatch, + *, + nfg_str=None, + nfg_raises=None, + efg_str=None, + efg_raises=None, + load_raises=None, + dynamics="sequential", +): + """Inject a fake pyspiel + open_spiel.python.algorithms.gambit into sys.modules. + + ``dynamics`` controls which export path the code takes: + - ``"sequential"`` → game.get_type().dynamics == pyspiel.GameType.Dynamics.SEQUENTIAL + (OpenSpiel's term for extensive-form / tree games in Gambit) + - ``"simultaneous"`` → SIMULTANEOUS (normal-form / strategic-form games in Gambit) + - ``"other"`` → any other value, triggers the unsupported-dynamics ValueError + """ + mock_ps = MagicMock() + mock_export_fn = MagicMock() + mock_game = MagicMock() + + # Wire the dynamics attribute so the == comparison in load_openspiel resolves correctly. + # MagicMock attribute access is idempotent: mock_ps.GameType.Dynamics.SEQUENTIAL always + # returns the same object, so the equality check passes. + if dynamics == "sequential": + mock_game.get_type.return_value.dynamics = mock_ps.GameType.Dynamics.SEQUENTIAL + elif dynamics == "simultaneous": + mock_game.get_type.return_value.dynamics = mock_ps.GameType.Dynamics.SIMULTANEOUS + else: + mock_game.get_type.return_value.dynamics = object() # matches neither branch + + if load_raises is not None: + mock_ps.load_game.side_effect = load_raises + else: + mock_ps.load_game.return_value = mock_game + + if nfg_raises is not None: + mock_ps.game_to_nfg_string.side_effect = nfg_raises + else: + mock_ps.game_to_nfg_string.return_value = nfg_str + + if efg_raises is not None: + mock_export_fn.side_effect = efg_raises + else: + mock_export_fn.return_value = efg_str + + mock_gambit_module = MagicMock() + mock_gambit_module.export_gambit = mock_export_fn + + monkeypatch.setitem(sys.modules, "pyspiel", mock_ps) + monkeypatch.setitem(sys.modules, "open_spiel", MagicMock()) + monkeypatch.setitem(sys.modules, "open_spiel.python", MagicMock()) + monkeypatch.setitem(sys.modules, "open_spiel.python.algorithms", MagicMock()) + monkeypatch.setitem(sys.modules, "open_spiel.python.algorithms.gambit", mock_gambit_module) + return mock_ps, mock_export_fn + + +def test_openspiel_load_efg_success(monkeypatch): + """Sequential (extensive-form) game: EFG export is used and returns a valid Game.""" + _setup_pyspiel_mock(monkeypatch, dynamics="sequential", efg_str=_MOCK_EFG) + game = gbt.catalog.load_openspiel("tiny_hanabi") + assert isinstance(game, gbt.Game) + + +def test_openspiel_load_nfg_success(monkeypatch): + """Simultaneous (normal-form) game: NFG export is used and returns a valid Game.""" + _setup_pyspiel_mock(monkeypatch, dynamics="simultaneous", nfg_str=_MOCK_NFG) + game = gbt.catalog.load_openspiel("matrix_rps") + assert isinstance(game, gbt.Game) + + +def test_openspiel_load_import_error(monkeypatch): + """Missing open_spiel raises ImportError with a helpful message.""" + monkeypatch.setitem(sys.modules, "pyspiel", None) + with pytest.raises(ImportError, match="open_spiel"): + gbt.catalog.load_openspiel("matrix_rps") + + +def test_openspiel_load_game_not_found(monkeypatch): + """pyspiel.load_game errors propagate directly without wrapping.""" + _setup_pyspiel_mock(monkeypatch, load_raises=RuntimeError("Unknown game 'bogus_game'")) + with pytest.raises(RuntimeError, match="Unknown game"): + gbt.catalog.load_openspiel("bogus_game") + + +def test_openspiel_load_efg_export_failure(monkeypatch): + """EFG export failure on a sequential game raises ValueError with format context.""" + _setup_pyspiel_mock( + monkeypatch, + dynamics="sequential", + efg_raises=RuntimeError("export error"), + ) + with pytest.raises(ValueError, match="EFG format"): + gbt.catalog.load_openspiel("tiny_hanabi") + + +def test_openspiel_load_nfg_export_failure(monkeypatch): + """NFG export failure on a simultaneous game raises ValueError with format context.""" + _setup_pyspiel_mock( + monkeypatch, + dynamics="simultaneous", + nfg_raises=RuntimeError("export error"), + ) + with pytest.raises(ValueError, match="NFG format"): + gbt.catalog.load_openspiel("matrix_rps") + + +def test_openspiel_load_unsupported_dynamics(monkeypatch): + """A game with unsupported dynamics (e.g. MEAN_FIELD) raises ValueError.""" + _setup_pyspiel_mock(monkeypatch, dynamics="other") + with pytest.raises(ValueError, match="unsupported dynamics"): + gbt.catalog.load_openspiel("some_mfg_game") + + +def test_openspiel_load_with_params(monkeypatch): + """params dict is forwarded verbatim to pyspiel.load_game.""" + mock_ps, _ = _setup_pyspiel_mock( + monkeypatch, dynamics="simultaneous", nfg_str=_MOCK_NFG + ) + gbt.catalog.load_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2}) + mock_ps.load_game.assert_called_once_with("blotto", {"players": 2, "coins": 3, "fields": 2})