From 6a6b25da204f6eb84c4e574071f2754a242739dd Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 26 Aug 2026 11:59:17 +0100 Subject: [PATCH 01/11] Draft enumpure and logit as python-driven tools --- pyproject.toml | 7 +- src/pygambit/cli/__init__.py | 22 +++ src/pygambit/cli/common.py | 254 +++++++++++++++++++++++++++++++++++ src/pygambit/cli/enumpure.py | 87 ++++++++++++ src/pygambit/cli/logit.py | 215 +++++++++++++++++++++++++++++ src/pygambit/gambit.pxd | 12 +- src/pygambit/nash.h | 26 ++-- src/pygambit/nash.pxi | 40 ++++-- src/pygambit/node.pxi | 7 + src/pygambit/qre.py | 54 +++++++- 10 files changed, 691 insertions(+), 33 deletions(-) create mode 100644 src/pygambit/cli/__init__.py create mode 100644 src/pygambit/cli/common.py create mode 100644 src/pygambit/cli/enumpure.py create mode 100644 src/pygambit/cli/logit.py diff --git a/pyproject.toml b/pyproject.toml index e2e4e55f26..23a655784b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,13 @@ dependencies = [ "numpy", "scipy", "pandas", + "click", ] +[project.scripts] +gambit-enumpure = "pygambit.cli.enumpure:main" +gambit-logit = "pygambit.cli.logit:main" + [project.optional-dependencies] test = ["pytest", "pytest-subtests", "nbformat", "nbclient", "ipykernel"] doc = [ @@ -124,7 +129,7 @@ markers = [ ] [tool.setuptools] -packages = ["pygambit"] +packages = ["pygambit", "pygambit.cli"] package-dir = { "pygambit" = "src/pygambit" } [tool.setuptools.package-data] diff --git a/src/pygambit/cli/__init__.py b/src/pygambit/cli/__init__.py new file mode 100644 index 0000000000..f25d3612a0 --- /dev/null +++ b/src/pygambit/cli/__init__.py @@ -0,0 +1,22 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/__init__.py +# Command-line tools for computing Nash equilibria, implemented in Python +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line tools for computing Nash equilibria, implemented in Python.""" diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py new file mode 100644 index 0000000000..59f8123053 --- /dev/null +++ b/src/pygambit/cli/common.py @@ -0,0 +1,254 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/common.py +# Infrastructure shared by Gambit's command-line tools +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Infrastructure shared by Gambit's command-line tools.""" + +from __future__ import annotations + +import functools +import io +import pathlib +import sys + +import click + +import pygambit as gbt + +_GAME_FORMATS = ( + ("NFG", gbt.read_nfg), + ("EFG", gbt.read_efg), + ("#AGG", gbt.read_agg), + ("#BAGG", gbt.read_bagg), +) + + +def _gambit_version() -> str: + """The Gambit version string, in the same (not necessarily PEP 440-normalized) + form as the C++ tools' ``VERSION`` macro, which is taken verbatim from + `build_support/GAMBIT_VERSION`. + """ + version_file = pathlib.Path(__file__).parents[3] / "build_support/GAMBIT_VERSION" + if version_file.exists(): + return version_file.read_text().strip() + return gbt.__version__ + + +def open_game_file(path: str | None, prog_name: str) -> io.IOBase: + """Open `path` for reading, or return standard input if `path` is `None` or + `"-"`. A failure to open the file is reported the way the C++ tools' call to + `perror()` does, and exits with status 1. + """ + if path is None or path == "-": + return sys.stdin.buffer + try: + return open(path, "rb") + except OSError as exc: + click.echo(f"{prog_name}: {path}: {exc.strerror}", err=True) + sys.exit(1) + + +def read_game(source: io.IOBase) -> gbt.Game: + """Read a game from an open file (or standard input), auto-detecting its file + format the way the underlying C++ tools' ``ReadGame()`` does: first as a + (legacy) ``.gbt`` workspace, and, failing that, by the leading token of the + file, which identifies it as an NFG, EFG, AGG, or BAGG representation. + """ + data = source.read() + if isinstance(data, str): + data = data.encode("utf-8") + if not data.strip(): + raise ValueError("Empty file or string provided") + try: + return gbt.read_gbt(io.BytesIO(data)) + except Exception: + pass + text = data.decode("utf-8", errors="replace").lstrip() + for tag, reader in _GAME_FORMATS: + if text.startswith(tag): + try: + return reader(io.BytesIO(data)) + except Exception as exc: + raise ValueError(f"Parse error in game file: {exc}") from None + raise ValueError("Unrecognized file format") + + +def print_banner(description: str) -> None: + """Print the standard Gambit command-line tool banner to standard error.""" + click.echo(description, err=True) + click.echo( + f"Gambit version {_gambit_version()}, Copyright (C) 1994-2026, The Gambit Project", + err=True, + ) + click.echo("This is free software, distributed under the GNU GPL", err=True) + click.echo(err=True) + + +def version_option(description: str) -> callable: + """A ``-v``/``--version`` option which prints the tool's banner and exits, + matching the behavior of the C++ command-line tools. + """ + + def callback(ctx: click.Context, _param: click.Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + print_banner(description) + ctx.exit(0) + + return click.option( + "-v", + "--version", + is_flag=True, + expose_value=False, + is_eager=True, + callback=callback, + help="print version information", + ) + + +def handle_errors(f: callable) -> callable: + """Decorator which reports errors the way the C++ command-line tools do: + a message on standard error and exit code 1, instead of a Python traceback. + """ + + @functools.wraps(f) + def wrapper(*args, **kwargs): + try: + f(*args, **kwargs) + except (ValueError, RuntimeError, OSError) as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + return wrapper + + +def format_value(value: gbt.Rational | float, decimals: int, fixed: bool = True) -> str: + """Format a probability or payoff value the way the C++ tools do: exactly, for a + `Rational`, or, for a float, fixed-point with `decimals` digits after the point + (`fixed=True`, matching `MixedStrategyProfileCSVRenderer`/`MixedBehaviorProfileCSVRenderer`), + or with `decimals` significant digits, trimmed of trailing zeroes and switching to + scientific notation as needed (`fixed=False`, matching `gambit-logit`'s own + `PrintProfile`, which explicitly unsets `std::ios::fixed` before printing profile + values). + """ + if isinstance(value, gbt.Rational): + return str(value) + return f"{value:.{decimals}f}" if fixed else f"{value:.{decimals}g}" + + +def _is_behavior_profile(profile) -> bool: + return "Behavior" in type(profile).__name__ + + +def render_profile_csv( + profile: gbt.MixedStrategyProfile | gbt.MixedBehaviorProfile, + label: str, + decimals: int, + fixed: bool = True, +) -> str: + """Render a strategy or behavior profile as a single comma-separated line, + tagged with `label`, matching `MixedStrategyProfileCSVRenderer` and + `MixedBehaviorProfileCSVRenderer` in the C++ tools. See `format_value` for + the meaning of `fixed`. + """ + if _is_behavior_profile(profile): + values = [ + prob + for player in profile.game.players + for _infoset, action in profile[player.label] + for _label, prob in action + ] + else: + values = [ + prob for player in profile.game.players for _label, prob in profile[player.label] + ] + return ",".join([label, *(format_value(v, decimals, fixed) for v in values)]) + + +def render_profile_detail( + profile: gbt.MixedStrategyProfile | gbt.MixedBehaviorProfile, + decimals: int, +) -> str: + """Render a strategy or behavior profile as a human-readable description, + matching `MixedStrategyProfileDetailRenderer` and + `MixedBehaviorProfileDetailRenderer` in the C++ tools. + """ + if _is_behavior_profile(profile): + return _render_behavior_detail(profile, decimals) + return _render_strategy_detail(profile, decimals) + + +def _name_or_number(obj) -> str: + # Gambit's Python API numbers players/strategies/infosets/actions from 0; + # the C++ tools display the underlying (1-based) engine numbering. + return obj.label if obj.label else str(obj.number + 1) + + +def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> str: + lines = [] + for player in profile.game.players: + lines.append(f"Strategy profile for player {player.number + 1}:") + lines.append("Strategy Prob Value") + lines.append("-------- ----------- -----------") + probs = profile[player.label] + values = profile.strategy_values[player.label] + for strategy in player.strategies: + name = _name_or_number(strategy) + prob = format_value(probs[strategy.label], decimals) + value = format_value(values[strategy.label], decimals) + lines.append(f"{name:>8} {prob:>10} {value:>11}") + return "\n".join(lines) + + +def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> str: + lines = [] + action_values = profile.action_values + beliefs = profile.beliefs + realiz_probs = profile.realiz_probs + for player in profile.game.players: + lines.append(f"Behavior profile for player {player.number + 1}:") + lines.append("Infoset Action Prob Value") + lines.append("------- ------- ----------- -----------") + for infoset, mixed_action in profile[player.label]: + infoset_name = _name_or_number(infoset) + values = action_values[next(iter(infoset.members))] + for action in infoset.actions: + prob = mixed_action[action.label] + value = values[action.label] + value_text = format_value(value, decimals) if value is not None else "" + lines.append( + f"{infoset_name:>7} {_name_or_number(action):>7} " + f"{format_value(prob, decimals):>11} {value_text:>11}" + ) + lines.append("") + lines.append("Infoset Node Belief Prob") + lines.append("------- ------- ----------- -----------") + for infoset, _mixed_action in profile[player.label]: + infoset_name = _name_or_number(infoset) + for node in infoset.members: + node_name = _name_or_number(node) + belief = beliefs[node] + belief_text = format_value(belief, decimals) if belief is not None else "" + realiz_text = format_value(realiz_probs[node], decimals) + lines.append( + f"{infoset_name:>7} {node_name:>7} {belief_text:>11} {realiz_text:>11}" + ) + lines.append("") + return "\n".join(lines) diff --git a/src/pygambit/cli/enumpure.py b/src/pygambit/cli/enumpure.py new file mode 100644 index 0000000000..4b7fd7a34c --- /dev/null +++ b/src/pygambit/cli/enumpure.py @@ -0,0 +1,87 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/enumpure.py +# Command-line driver program for pure-strategy Nash equilibrium enumeration +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for pure-strategy Nash equilibrium enumeration.""" + +from __future__ import annotations + +import click + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + render_profile_csv, + render_profile_detail, + version_option, +) + +DESCRIPTION = "Search for Nash equilibria in pure strategies" +PROG_NAME = "gambit-enumpure" + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified. " + "With no options, locates all Nash equilibria in pure strategies." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-S", + "--strategic", + is_flag=True, + help="report equilibria in strategies even for extensive games", +) +@click.option("-A", "--agent", is_flag=True, help="compute agent form equilibria") +@click.option("-D", "--detail", is_flag=True, help="print detailed information about equilibria") +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@version_option(DESCRIPTION) +@handle_errors +def main(file: str | None, strategic: bool, agent: bool, detail: bool, quiet: bool) -> None: + if not quiet: + print_banner(DESCRIPTION) + game = read_game(open_game_file(file, PROG_NAME)) + + def render(profile) -> None: + is_behavior = hasattr(profile, "as_strategy") + if strategic and is_behavior: + profile = profile.as_strategy() + elif not strategic and game.is_tree and not is_behavior: + profile = profile.as_behavior() + if detail: + click.echo(render_profile_detail(profile, 0)) + else: + click.echo(render_profile_csv(profile, "NE", 0)) + + if agent and game.is_tree: + gbt.nash.enumpure_agent_solve(game, nash_callback=render) + else: + gbt.nash.enumpure_solve(game, nash_callback=render) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/cli/logit.py b/src/pygambit/cli/logit.py new file mode 100644 index 0000000000..9a34f2c699 --- /dev/null +++ b/src/pygambit/cli/logit.py @@ -0,0 +1,215 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/logit.py +# Command-line driver program for quantal response equilibrium tracing +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for quantal response equilibrium tracing.""" + +from __future__ import annotations + +import pathlib + +import click + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + render_profile_csv, + version_option, +) + +DESCRIPTION = "Compute a branch of the logit equilibrium correspondence" +PROG_NAME = "gambit-logit" + + +def _render_qre_row(qre, decimals: int) -> str: + """Render a QRE point (anything exposing `lam`, `profile`, and `log_like`, as + `LogitQREMixedStrategyProfile`/`LogitQREMixedBehaviorProfile` and + `LogitQREMixedStrategyFitResult`/`LogitQREMixedBehaviorFitResult` all do) as a + single comma-separated line, matching `gambit-logit`'s own `PrintProfile`: the + lambda label is fixed-point, the profile values use `decimals` significant + digits, and the log-likelihood is appended, fixed-point, when it is meaningful + (non-positive). + """ + label = f"{qre.lam:.{decimals}f}" + text = render_profile_csv(qre.profile, label, decimals, fixed=False) + if qre.log_like <= 0.0: + text += f",{qre.log_like:.{decimals}f}" + return text + + +def _read_frequencies(path: str, game: gbt.Game) -> gbt.MixedStrategyProfileDouble: + """Read observed strategy frequencies for maximum-likelihood estimation: a flat, + comma-separated list of counts, one per strategy, in the same order as a profile's + CSV row, matching the C++ tool's `ReadProfile`. + """ + count = sum(len(list(player.strategies)) for player in game.players) + try: + fields = pathlib.Path(path).read_text().split(",") + values = [float(fields[i]) for i in range(count)] + except (OSError, ValueError, IndexError): + raise ValueError(f"Error reading strategy frequencies from '{path}'.") from None + frequencies = game.mixed_strategy_profile(rational=False) + it = iter(values) + for player in game.players: + frequencies[player.label] = {s.label: next(it) for s in player.strategies} + return frequencies + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-d", + "decimals", + default=6, + show_default=True, + type=int, + help="show equilibria as floating point with DECIMALS digits", +) +@click.option("-S", "--strategic", is_flag=True, help="use strategic game") +@click.option( + "-s", + "first_step", + default=0.03, + show_default=True, + type=float, + help="initial stepsize for the tracing procedure", +) +@click.option( + "-a", + "max_accel", + default=1.1, + show_default=True, + type=float, + help="maximum acceleration of the step size during the tracing procedure", +) +@click.option( + "-m", + "maxregret", + default=1.0e-8, + show_default=True, + type=float, + help="maximum regret acceptable as a proportion of the range of payoffs in the game", +) +@click.option( + "-l", + "target_lambda", + multiple=True, + type=float, + help="compute the QRE at LAMBDA accurately; may be specified multiple times", +) +@click.option( + "-L", + "mle_file", + type=str, + default=None, + help="compute maximum likelihood estimate; read strategy frequencies from FILE", +) +@click.option( + "-e", + "--terminal-only", + is_flag=True, + help="print only the terminal equilibrium (default is to print the entire branch)", +) +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@version_option(DESCRIPTION) +@handle_errors +def main( + file: str | None, + decimals: int, + strategic: bool, + first_step: float, + max_accel: float, + maxregret: float, + target_lambda: tuple[float, ...], + mle_file: str | None, + terminal_only: bool, + quiet: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION) + game = read_game(open_game_file(file, PROG_NAME)) + if not game.is_perfect_recall: + raise ValueError("Computing equilibria of games with imperfect recall is not supported.") + + def stream(qre) -> None: + click.echo(_render_qre_row(qre, decimals)) + + event_callback = None if terminal_only else stream + + # Maximum-likelihood estimation, like the C++ tool, is only defined over the + # strategic representation, since the observed frequencies are read as a flat + # list of strategy counts. + if mle_file is not None and (strategic or not game.is_tree): + frequencies = _read_frequencies(mle_file, game) + result = gbt.qre.logit_estimate( + frequencies, + first_step=first_step, + max_accel=max_accel, + event_callback=event_callback, + ) + click.echo(_render_qre_row(result, decimals)) + return + + if target_lambda: + for qre in gbt.qre.logit_solve_lambda( + game, + list(target_lambda), + use_strategic=strategic, + first_step=first_step, + max_accel=max_accel, + event_callback=event_callback, + ): + click.echo(_render_qre_row(qre, decimals)) + return + + if terminal_only: + result = gbt.nash.logit_solve( + game, + use_strategic=strategic, + maxregret=maxregret, + first_step=first_step, + max_accel=max_accel, + ) + click.echo(render_profile_csv(result.equilibria[-1], "NE", decimals, fixed=False)) + return + + result = gbt.nash.logit_solve( + game, + use_strategic=strategic, + maxregret=maxregret, + first_step=first_step, + max_accel=max_accel, + event_callback=stream, + ) + click.echo(render_profile_csv(result.equilibria[-1], "NE", decimals, fixed=False)) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index fe51bae0e8..f436ccede0 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -697,10 +697,12 @@ cdef extern from "nash.h": c_Game, double, double, double ) except + stdlist[shared_ptr[c_LogitQREMixedBehaviorProfile]] LogitBehaviorAtLambdaWrapper( - c_Game, stdlist[double], double, double + c_Game, stdlist[double], double, double, + LogitEventCallbackType[c_LogitQREMixedBehaviorProfile] ) except + shared_ptr[c_LogitQREMixedBehaviorProfile] LogitBehaviorEstimateWrapper( - shared_ptr[c_MixedBehaviorProfile[double]], bool, double, double + shared_ptr[c_MixedBehaviorProfile[double]], bool, double, double, + LogitEventCallbackType[c_LogitQREMixedBehaviorProfile] ) except + stdlist[c_MixedStrategyProfile[double]] LogitStrategySolveWrapper( c_Game, double, double, double, LogitEventCallbackType[c_LogitQREMixedStrategyProfile] @@ -709,8 +711,10 @@ cdef extern from "nash.h": c_Game, double, double, double ) except + stdlist[shared_ptr[c_LogitQREMixedStrategyProfile]] LogitStrategyAtLambdaWrapper( - c_Game, stdlist[double], double, double + c_Game, stdlist[double], double, double, + LogitEventCallbackType[c_LogitQREMixedStrategyProfile] ) except + shared_ptr[c_LogitQREMixedStrategyProfile] LogitStrategyEstimateWrapper( - shared_ptr[c_MixedStrategyProfile[double]], bool, double, double + shared_ptr[c_MixedStrategyProfile[double]], bool, double, double, + LogitEventCallbackType[c_LogitQREMixedStrategyProfile] ) except + diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 1e3b3609a5..2c96593e36 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -50,19 +50,24 @@ LogitBehaviorPrincipalBranchWrapper(const Game &p_game, double p_regret, double std::shared_ptr LogitBehaviorEstimateWrapper(std::shared_ptr> p_frequencies, - bool p_stopAtLocal, double p_firstStep, double p_maxAccel) + bool p_stopAtLocal, double p_firstStep, double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback) { return make_shared(LogitBehaviorEstimate( - *p_frequencies, 1000000.0, 1.0, p_stopAtLocal, p_firstStep, p_maxAccel)); + *p_frequencies, 1000000.0, 1.0, p_stopAtLocal, p_firstStep, p_maxAccel, p_onEvent)); } std::list> LogitBehaviorAtLambdaWrapper(const Game &p_game, const std::list &p_targetLambda, - double p_firstStep, double p_maxAccel) + double p_firstStep, double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback) { LogitQREMixedBehaviorProfile start(p_game); std::list> ret; - for (auto &qre : LogitBehaviorSolveLambda(start, p_targetLambda, 1.0, p_firstStep, p_maxAccel)) { + for (auto &qre : + LogitBehaviorSolveLambda(start, p_targetLambda, 1.0, p_firstStep, p_maxAccel, p_onEvent)) { ret.push_back(std::make_shared(qre)); } return ret; @@ -93,11 +98,14 @@ LogitStrategyPrincipalBranchWrapper(const Game &p_game, double p_regret, double std::list> LogitStrategyAtLambdaWrapper(const Game &p_game, const std::list &p_targetLambda, - double p_firstStep, double p_maxAccel) + double p_firstStep, double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback) { LogitQREMixedStrategyProfile start(p_game); std::list> ret; - for (auto &qre : LogitStrategySolveLambda(start, p_targetLambda, 1.0, p_firstStep, p_maxAccel)) { + for (auto &qre : + LogitStrategySolveLambda(start, p_targetLambda, 1.0, p_firstStep, p_maxAccel, p_onEvent)) { ret.push_back(std::make_shared(qre)); } return ret; @@ -105,8 +113,10 @@ LogitStrategyAtLambdaWrapper(const Game &p_game, const std::list &p_targ std::shared_ptr LogitStrategyEstimateWrapper(std::shared_ptr> p_frequencies, - bool p_stopAtLocal, double p_firstStep, double p_maxAccel) + bool p_stopAtLocal, double p_firstStep, double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback) { return make_shared(LogitStrategyEstimate( - *p_frequencies, 1000000.0, 1.0, p_stopAtLocal, p_firstStep, p_maxAccel)); + *p_frequencies, 1000000.0, 1.0, p_stopAtLocal, p_firstStep, p_maxAccel, p_onEvent)); } diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index 1a681f8ffc..47d16415bc 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -737,19 +737,24 @@ class LogitQREMixedStrategyProfile: def _logit_strategy_estimate(profile: MixedStrategyProfileDouble, local_max: bool = False, first_step: float = .03, - max_accel: float = 1.1) -> LogitQREMixedStrategyProfile: + max_accel: float = 1.1, + event_callback: object = None) -> LogitQREMixedStrategyProfile: """Estimate QRE corresponding to mixed strategy profile using maximum likelihood along the principal branch. """ return LogitQREMixedStrategyProfile.wrap( - LogitStrategyEstimateWrapper(profile.profile, local_max, first_step, max_accel) + LogitStrategyEstimateWrapper( + profile.profile, local_max, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedStrategyProfile](event_callback) + ) ) def _logit_strategy_lambda(game: Game, lam: float | list[float], first_step: float = .03, - max_accel: float = 1.1) -> list[LogitQREMixedStrategyProfile]: + max_accel: float = 1.1, + event_callback: object = None) -> list[LogitQREMixedStrategyProfile]: """Compute the first QRE encountered along the principal branch of the strategic game corresponding to lambda value `lam`. """ @@ -758,7 +763,10 @@ def _logit_strategy_lambda(game: Game, except TypeError: lam = [lam] return [LogitQREMixedStrategyProfile.wrap(profile) - for profile in LogitStrategyAtLambdaWrapper(game.game, lam, first_step, max_accel)] + for profile in LogitStrategyAtLambdaWrapper( + game.game, lam, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedStrategyProfile](event_callback) + )] def _logit_strategy_branch(game: Game, @@ -791,9 +799,7 @@ class LogitQREMixedBehaviorProfile: @property def game(self) -> Game: """The game on which this mixed strategy profile is defined.""" - g = Game() - g.game = deref(self.thisptr).GetGame() - return g + return Game.wrap(deref(self.thisptr).GetGame()) @property def lam(self) -> double: @@ -808,29 +814,32 @@ class LogitQREMixedBehaviorProfile: @property def profile(self) -> MixedBehaviorProfileDouble: """The mixed strategy profile.""" - profile = MixedBehaviorProfileDouble() - profile.profile = ( + return MixedBehaviorProfileDouble.wrap( make_shared[c_MixedBehaviorProfile[double]](deref(self.thisptr).GetProfile()) ) - return profile def _logit_behavior_estimate(profile: MixedBehaviorProfileDouble, local_max: bool = False, first_step: float = .03, - max_accel: float = 1.1) -> LogitQREMixedBehaviorProfile: + max_accel: float = 1.1, + event_callback: object = None) -> LogitQREMixedBehaviorProfile: """Estimate QRE corresponding to mixed behavior profile using maximum likelihood along the principal branch. """ ret = LogitQREMixedBehaviorProfile(profile.game) - ret.thisptr = LogitBehaviorEstimateWrapper(profile.profile, local_max, first_step, max_accel) + ret.thisptr = LogitBehaviorEstimateWrapper( + profile.profile, local_max, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) + ) return ret def _logit_behavior_lambda(game: Game, lam: float | list[float], first_step: float = .03, - max_accel: float = 1.1) -> list[LogitQREMixedBehaviorProfile]: + max_accel: float = 1.1, + event_callback: object = None) -> list[LogitQREMixedBehaviorProfile]: """Compute the first QRE encountered along the principal branch of the extensive game corresponding to lambda value `lam`. """ @@ -839,7 +848,10 @@ def _logit_behavior_lambda(game: Game, except TypeError: lam = [lam] ret = [] - for profile in LogitBehaviorAtLambdaWrapper(game.game, lam, first_step, max_accel): + for profile in LogitBehaviorAtLambdaWrapper( + game.game, lam, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) + ): qre = LogitQREMixedBehaviorProfile() qre.thisptr = profile ret.append(qre) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index fc6e1b19af..42f441f357 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -385,6 +385,13 @@ class Node: def label(self, value: str) -> None: self.node.deref().SetLabel(value.encode("utf-8")) + @property + def number(self) -> int: + """Returns the number of the node in its game. + Nodes are numbered starting with 0. + """ + return self.node.deref().GetNumber() - 1 + @property def children(self) -> NodeChildren: """The set of children of this node.""" diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index ced17f49e9..eaff807af8 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -25,6 +25,7 @@ from __future__ import annotations import math +from collections.abc import Callable import scipy.optimize @@ -56,15 +57,32 @@ def logit_solve_lambda( use_strategic: bool = False, first_step: float = .03, max_accel: float = 1.1, + event_callback: Callable[ + [libgbt.LogitQREMixedStrategyProfile | libgbt.LogitQREMixedBehaviorProfile], None + ] | None = None, ): + """Compute the QRE(s) at the specified value(s) of `lam` along the principal branch. + + Parameters + ---------- + event_callback : Callable, optional + If specified, called with each point traced along the principal branch on the + way to each requested value of `lam`. + + .. versionadded:: 17.0.0 + """ if first_step <= 0.0: raise ValueError("logit_solve_lambda(): first_step argument must be positive") if max_accel < 1.0: raise ValueError("logit_solve_lambda(): max_accel argument must be at least 1.0") if not game.is_tree or use_strategic: - return libgbt._logit_strategy_lambda(game, lam, first_step, max_accel) + return libgbt._logit_strategy_lambda( + game, lam, first_step, max_accel, event_callback + ) else: - return libgbt._logit_behavior_lambda(game, lam, first_step, max_accel) + return libgbt._logit_behavior_lambda( + game, lam, first_step, max_accel, event_callback + ) class LogitQREMixedStrategyFitResult: @@ -166,9 +184,11 @@ def _estimate_strategy_fixedpoint( local_max: bool = False, first_step: float = .03, max_accel: float = 1.1, + event_callback: object = None, ) -> LogitQREMixedStrategyFitResult: res = libgbt._logit_strategy_estimate(data, local_max=local_max, - first_step=first_step, max_accel=max_accel) + first_step=first_step, max_accel=max_accel, + event_callback=event_callback) return LogitQREMixedStrategyFitResult( data, "fixedpoint", res.lam, res.profile, res.log_like ) @@ -179,9 +199,11 @@ def _estimate_behavior_fixedpoint( local_max: bool = False, first_step: float = .03, max_accel: float = 1.1, + event_callback: object = None, ) -> LogitQREMixedBehaviorFitResult: res = libgbt._logit_behavior_estimate(data, local_max=local_max, - first_step=first_step, max_accel=max_accel) + first_step=first_step, max_accel=max_accel, + event_callback=event_callback) return LogitQREMixedBehaviorFitResult( data, "fixedpoint", res.lam, res.profile, res.log_like ) @@ -263,6 +285,9 @@ def logit_estimate( local_max: bool = False, first_step: float = .03, max_accel: float = 1.1, + event_callback: Callable[ + [libgbt.LogitQREMixedStrategyProfile | libgbt.LogitQREMixedBehaviorProfile], None + ] | None = None, ) -> LogitQREMixedStrategyFitResult | LogitQREMixedBehaviorFitResult: """Use maximum likelihood estimation to find the logit quantal response equilibrium which best fits empirical frequencies of play. @@ -311,6 +336,17 @@ def logit_estimate( This argument only has an effect when use_empirical is False. + event_callback : Callable[[LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile], \ +None], optional + If specified, called with each point traced along the principal branch on the + way to the best-fitting QRE. + + .. note:: + + This argument only has an effect when use_empirical is False. + + .. versionadded:: 17.0.0 + Returns ------- LogitQREMixedStrategyFitResult or LogitQREMixedBehaviorFitResult @@ -324,17 +360,23 @@ def logit_estimate( as a structural model for estimation: The missing manual. SSRN working paper 4425515. """ + if use_empirical and event_callback is not None: + raise ValueError( + "logit_estimate(): event_callback cannot be used with use_empirical" + ) if isinstance(data, libgbt.MixedStrategyProfile): if use_empirical: return _estimate_strategy_empirical(data) else: return _estimate_strategy_fixedpoint(data, local_max=local_max, - first_step=first_step, max_accel=max_accel) + first_step=first_step, max_accel=max_accel, + event_callback=event_callback) elif isinstance(data, libgbt.MixedBehaviorProfile): if use_empirical: return _estimate_behavior_empirical(data) else: return _estimate_behavior_fixedpoint(data, local_max=local_max, - first_step=first_step, max_accel=max_accel) + first_step=first_step, max_accel=max_accel, + event_callback=event_callback) else: raise TypeError("data must be specified as a MixedStrategyProfile or MixedBehaviorProfile") From 2b27b803c0151fa4c3fb18452c48ddd250d9bbd6 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 26 Aug 2026 12:42:33 +0100 Subject: [PATCH 02/11] Port over two-player game command-line tools --- pyproject.toml | 3 + src/pygambit/cli/enummixed.py | 86 ++++++++++++++++++++++ src/pygambit/cli/lcp.py | 132 ++++++++++++++++++++++++++++++++++ src/pygambit/cli/lp.py | 95 ++++++++++++++++++++++++ src/pygambit/gambit.pxd | 6 ++ src/pygambit/nash.h | 15 ++++ src/pygambit/nash.pxi | 29 ++++++++ src/pygambit/nash.py | 32 ++++++++- 8 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 src/pygambit/cli/enummixed.py create mode 100644 src/pygambit/cli/lcp.py create mode 100644 src/pygambit/cli/lp.py diff --git a/pyproject.toml b/pyproject.toml index 23a655784b..0d9f6be9ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ [project.scripts] gambit-enumpure = "pygambit.cli.enumpure:main" gambit-logit = "pygambit.cli.logit:main" +gambit-enummixed = "pygambit.cli.enummixed:main" +gambit-lcp = "pygambit.cli.lcp:main" +gambit-lp = "pygambit.cli.lp:main" [project.optional-dependencies] test = ["pytest", "pytest-subtests", "nbformat", "nbclient", "ipykernel"] diff --git a/src/pygambit/cli/enummixed.py b/src/pygambit/cli/enummixed.py new file mode 100644 index 0000000000..96039fdecb --- /dev/null +++ b/src/pygambit/cli/enummixed.py @@ -0,0 +1,86 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/enummixed.py +# Command-line driver program for mixed-strategy Nash equilibrium enumeration +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for mixed-strategy Nash equilibrium enumeration.""" + +from __future__ import annotations + +import click + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + render_profile_csv, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria by enumerating extreme points" +PROG_NAME = "gambit-enummixed" + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified. " + "With no options, reports all Nash equilibria found." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-d", + "decimals", + type=int, + default=None, + help="compute using floating-point arithmetic; display results with DECIMALS digits", +) +@click.option("-c", "--cliques", is_flag=True, help="output connectedness information") +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@version_option(DESCRIPTION) +@handle_errors +def main(file: str | None, decimals: int | None, cliques: bool, quiet: bool) -> None: + if not quiet: + print_banner(DESCRIPTION) + game = read_game(open_game_file(file, PROG_NAME)) + rational = decimals is None + + def render(profile) -> None: + click.echo(render_profile_csv(profile, "NE", decimals or 0)) + + result = gbt.nash.enummixed_solve( + game, + rational=rational, + nash_callback=render, + cliques=cliques, + ) + if cliques: + for index, clique in enumerate(result.parameters["cliques"], start=1): + label = f"convex-{index}" + for profile in clique: + click.echo(render_profile_csv(profile, label, decimals or 0)) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/cli/lcp.py b/src/pygambit/cli/lcp.py new file mode 100644 index 0000000000..854a68aceb --- /dev/null +++ b/src/pygambit/cli/lcp.py @@ -0,0 +1,132 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/lcp.py +# Command-line driver program for Nash equilibrium computation via LCP +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for Nash equilibrium computation via linear +complementarity programming. +""" + +from __future__ import annotations + +import sys + +import click + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + render_profile_csv, + render_profile_detail, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria by solving a linear complementarity program" +PROG_NAME = "gambit-lcp" + + +def _validate_stop_after( + ctx: click.Context, param: click.Parameter, value: str | None +) -> int | None: + if value is None: + return None + if not value.isdigit() or int(value) == 0: + click.echo(f"Error: -e argument must be a positive integer; got '{value}'.", err=True) + sys.exit(1) + return int(value) + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified. " + "For an extensive game, computes one equilibrium via the sequence form. " + "For a strategic game (or with -S), with no options, reports all accessible " + "Nash equilibria." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-d", + "decimals", + type=int, + default=None, + help="compute using floating-point arithmetic; display results with DECIMALS digits", +) +@click.option("-S", "--strategic", is_flag=True, help="use strategic game") +@click.option( + "-e", + "stop_after", + type=str, + default=None, + callback=_validate_stop_after, + help="terminate after finding EQA equilibria " + "(strategic games only; default is to find all accessible equilibria)", +) +@click.option( + "-r", + "max_depth", + type=int, + default=0, + help="terminate recursion at DEPTH " + "(strategic games only; only if number of equilibria sought is not 1)", +) +@click.option("-D", "--detail", is_flag=True, help="print detailed information about equilibria") +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@version_option(DESCRIPTION) +@handle_errors +def main( + file: str | None, + decimals: int | None, + strategic: bool, + stop_after: int | None, + max_depth: int, + detail: bool, + quiet: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION) + game = read_game(open_game_file(file, PROG_NAME)) + rational = decimals is None + render_decimals = decimals or 0 + use_strategic = strategic or not game.is_tree + + def render(profile) -> None: + if detail: + click.echo(render_profile_detail(profile, render_decimals)) + else: + click.echo(render_profile_csv(profile, "NE", render_decimals)) + + gbt.nash.lcp_solve( + game, + rational=rational, + use_strategic=strategic, + stop_after=stop_after if use_strategic else None, + max_depth=max_depth if use_strategic else None, + nash_callback=render, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/cli/lp.py b/src/pygambit/cli/lp.py new file mode 100644 index 0000000000..3e388423eb --- /dev/null +++ b/src/pygambit/cli/lp.py @@ -0,0 +1,95 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/lp.py +# Command-line driver program for Nash equilibrium computation via LP +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for Nash equilibrium computation via linear +programming, for two-player constant-sum games. +""" + +from __future__ import annotations + +import click + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + render_profile_csv, + render_profile_detail, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria by solving a linear program" +PROG_NAME = "gambit-lp" + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified. " + "With no options, computes one Nash equilibrium." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-d", + "decimals", + type=int, + default=None, + help="compute using floating-point arithmetic; display results with DECIMALS digits", +) +@click.option("-S", "--strategic", is_flag=True, help="use strategic game") +@click.option("-D", "--detail", is_flag=True, help="print detailed information about equilibria") +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@version_option(DESCRIPTION) +@handle_errors +def main( + file: str | None, + decimals: int | None, + strategic: bool, + detail: bool, + quiet: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION) + game = read_game(open_game_file(file, PROG_NAME)) + rational = decimals is None + render_decimals = decimals or 0 + + def render(profile) -> None: + if detail: + click.echo(render_profile_detail(profile, render_decimals)) + else: + click.echo(render_profile_csv(profile, "NE", render_decimals)) + + gbt.nash.lp_solve( + game, + rational=rational, + use_strategic=strategic, + nash_callback=render, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index f436ccede0..2071cd60a5 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -6,6 +6,7 @@ from libcpp.vector cimport vector as stdvector from libcpp.set cimport set as stdset from libcpp.map cimport map as stdmap from libcpp.optional cimport optional +from libcpp.pair cimport pair cdef extern from "games.h": @@ -690,6 +691,11 @@ cdef extern from "solvers/logit/logit.h": cdef extern from "nash.h": + pair[ + stdlist[c_MixedStrategyProfile[T]], stdlist[stdlist[c_MixedStrategyProfile[T]]] + ] EnumMixedStrategySolveCliquesWrapper[T]( + c_Game, StrategyCallbackType[T] + ) except +RuntimeError stdlist[c_MixedBehaviorProfile[double]] LogitBehaviorSolveWrapper( c_Game, double, double, double, LogitEventCallbackType[c_LogitQREMixedBehaviorProfile] ) except + diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 2c96593e36..96091b85b8 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -20,11 +20,26 @@ // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. // +#include "solvers/enummixed/enummixed.h" #include "solvers/logit/logit.h" using namespace std; using namespace Gambit; +template +std::pair>, std::list>>> +EnumMixedStrategySolveCliquesWrapper( + const Game &p_game, + Nash::StrategyCallbackType p_onEquilibrium = Nash::NullStrategyCallback) +{ + auto solution = Nash::EnumMixedStrategySolveDetailed(p_game, p_onEquilibrium); + std::list>> cliques; + for (auto &clique : solution->GetCliques()) { + cliques.emplace_back(clique.begin(), clique.end()); + } + return {solution->GetExtremeEquilibria(), cliques}; +} + std::list> LogitBehaviorSolveWrapper(const Game &p_game, double p_regret, double p_firstStep, double p_maxAccel, diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index 47d16415bc..d3b471c7fe 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -471,6 +471,35 @@ def _enummixed_strategy_solve_rational( ) +def _enummixed_strategy_solve_cliques_double( + game: Game, nash_callback: object = None +) -> tuple[list[MixedStrategyProfileDouble], list[list[MixedStrategyProfileDouble]]]: + result: pair[ + stdlist[c_MixedStrategyProfile[float]], stdlist[stdlist[c_MixedStrategyProfile[float]]] + ] = EnumMixedStrategySolveCliquesWrapper[double]( + game.game, MakeStrategyCallback[double](nash_callback) + ) + return ( + _convert_mspd(result.first), + [_convert_mspd(clique) for clique in result.second], + ) + + +def _enummixed_strategy_solve_cliques_rational( + game: Game, nash_callback: object = None +) -> tuple[list[MixedStrategyProfileRational], list[list[MixedStrategyProfileRational]]]: + result: pair[ + stdlist[c_MixedStrategyProfile[c_Rational]], + stdlist[stdlist[c_MixedStrategyProfile[c_Rational]]] + ] = EnumMixedStrategySolveCliquesWrapper[c_Rational]( + game.game, MakeStrategyCallback[c_Rational](nash_callback) + ) + return ( + _convert_mspr(result.first), + [_convert_mspr(clique) for clique in result.second], + ) + + def _lcp_behavior_solve_double( game: Game, nash_callback: object = None ) -> list[MixedBehaviorProfileDouble]: diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 017049e962..534b58ab20 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -158,6 +158,7 @@ def enummixed_solve( nash_callback: Callable[ [libgbt.MixedStrategyProfile], None ] | None = None, + cliques: bool = False, ) -> NashComputationResult: """Compute all :ref:`mixed-strategy Nash equilibria ` of a two-player game using the strategic representation. @@ -184,6 +185,13 @@ def enummixed_solve( .. versionadded:: 17.0.0 + cliques : bool, default False + If specified and True, also compute the sets of extreme equilibria which + are connected to one another, returned as a list of lists of equilibria in + `parameters["cliques"]`. Not available when `lrsnash_path` is specified. + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -195,7 +203,8 @@ def enummixed_solve( If game has more than two players. ValueError - If both `lrsnash_path` and `nash_callback` are specified. + If both `lrsnash_path` and `nash_callback` are specified, or both + `lrsnash_path` and `cliques` are specified. Notes ----- @@ -206,6 +215,10 @@ def enummixed_solve( raise ValueError( "enummixed_solve(): nash_callback cannot be used with lrsnash_path" ) + if cliques: + raise ValueError( + "enummixed_solve(): cliques cannot be used with lrsnash_path" + ) equilibria = nashlrs.lrsnash_solve(game, lrsnash_path=lrsnash_path) return NashComputationResult( game=game, @@ -215,6 +228,23 @@ def enummixed_solve( parameters={"lrsnash_path": lrsnash_path}, equilibria=equilibria, ) + if cliques: + if rational: + equilibria, clique_list = libgbt._enummixed_strategy_solve_cliques_rational( + game, nash_callback + ) + else: + equilibria, clique_list = libgbt._enummixed_strategy_solve_cliques_double( + game, nash_callback + ) + return NashComputationResult( + game=game, + method="enummixed", + rational=rational, + use_strategic=True, + equilibria=equilibria, + parameters={"cliques": clique_list}, + ) if rational: equilibria = libgbt._enummixed_strategy_solve_rational(game, nash_callback) else: From 2d2593f3741800bc32981920c0dd13423e8c1f77 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 26 Aug 2026 13:23:10 +0100 Subject: [PATCH 03/11] Migrate rest of solver tools --- pyproject.toml | 5 + src/pygambit/cli/common.py | 136 +++++++++++++++++++++++-- src/pygambit/cli/enumpoly.py | 147 +++++++++++++++++++++++++++ src/pygambit/cli/gnm.py | 192 +++++++++++++++++++++++++++++++++++ src/pygambit/cli/ipa.py | 140 +++++++++++++++++++++++++ src/pygambit/cli/lcp.py | 16 +-- src/pygambit/cli/liap.py | 186 +++++++++++++++++++++++++++++++++ src/pygambit/cli/simpdiv.py | 185 +++++++++++++++++++++++++++++++++ src/pygambit/qre.py | 2 +- 9 files changed, 984 insertions(+), 25 deletions(-) create mode 100644 src/pygambit/cli/enumpoly.py create mode 100644 src/pygambit/cli/gnm.py create mode 100644 src/pygambit/cli/ipa.py create mode 100644 src/pygambit/cli/liap.py create mode 100644 src/pygambit/cli/simpdiv.py diff --git a/pyproject.toml b/pyproject.toml index 0d9f6be9ad..9f5ebbff88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,11 @@ gambit-logit = "pygambit.cli.logit:main" gambit-enummixed = "pygambit.cli.enummixed:main" gambit-lcp = "pygambit.cli.lcp:main" gambit-lp = "pygambit.cli.lp:main" +gambit-liap = "pygambit.cli.liap:main" +gambit-simpdiv = "pygambit.cli.simpdiv:main" +gambit-gnm = "pygambit.cli.gnm:main" +gambit-ipa = "pygambit.cli.ipa:main" +gambit-enumpoly = "pygambit.cli.enumpoly:main" [project.optional-dependencies] test = ["pytest", "pytest-subtests", "nbformat", "nbclient", "ipykernel"] diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index 59f8123053..acf1515e01 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -90,9 +90,15 @@ def read_game(source: io.IOBase) -> gbt.Game: raise ValueError("Unrecognized file format") -def print_banner(description: str) -> None: - """Print the standard Gambit command-line tool banner to standard error.""" +def print_banner(description: str, extra_lines: tuple[str, ...] = ()) -> None: + """Print the standard Gambit command-line tool banner to standard error. Some + tools (`gambit-gnm`, `gambit-ipa`) insert additional lines, such as a Gametracer + credit, between the description and the Gambit version line; pass those as + `extra_lines`. + """ click.echo(description, err=True) + for line in extra_lines: + click.echo(line, err=True) click.echo( f"Gambit version {_gambit_version()}, Copyright (C) 1994-2026, The Gambit Project", err=True, @@ -101,15 +107,16 @@ def print_banner(description: str) -> None: click.echo(err=True) -def version_option(description: str) -> callable: +def version_option(description: str, extra_lines: tuple[str, ...] = ()) -> callable: """A ``-v``/``--version`` option which prints the tool's banner and exits, - matching the behavior of the C++ command-line tools. + matching the behavior of the C++ command-line tools. See `print_banner` for + the meaning of `extra_lines`. """ def callback(ctx: click.Context, _param: click.Parameter, value: bool) -> None: if not value or ctx.resilient_parsing: return - print_banner(description) + print_banner(description, extra_lines) ctx.exit(0) return click.option( @@ -139,17 +146,41 @@ def wrapper(*args, **kwargs): return wrapper -def format_value(value: gbt.Rational | float, decimals: int, fixed: bool = True) -> str: +def validate_stop_after( + ctx: click.Context, param: click.Parameter, value: str | None +) -> int | None: + """A click callback for a ``-e``/``stop_after``-style option: validates that `value` + is a positive integer, matching the C++ tools' `std::from_chars`-based validation, + reporting the same error text and exiting immediately (before the tool's banner) + on failure. + """ + if value is None: + return None + if not value.isdigit() or int(value) == 0: + click.echo(f"Error: -e argument must be a positive integer; got '{value}'.", err=True) + sys.exit(1) + return int(value) + + +def format_value( + value: gbt.Rational | float, + decimals: int, + fixed: bool = True, + as_float: bool = False, +) -> str: """Format a probability or payoff value the way the C++ tools do: exactly, for a `Rational`, or, for a float, fixed-point with `decimals` digits after the point (`fixed=True`, matching `MixedStrategyProfileCSVRenderer`/`MixedBehaviorProfileCSVRenderer`), or with `decimals` significant digits, trimmed of trailing zeroes and switching to scientific notation as needed (`fixed=False`, matching `gambit-logit`'s own `PrintProfile`, which explicitly unsets `std::ios::fixed` before printing profile - values). + values). If `as_float` is True, a `Rational` is first converted to a float and + formatted the same way as a float would be, matching `gambit-simpdiv`'s + `MixedStrategyCSVAsFloatRenderer`. """ - if isinstance(value, gbt.Rational): + if isinstance(value, gbt.Rational) and not as_float: return str(value) + value = float(value) return f"{value:.{decimals}f}" if fixed else f"{value:.{decimals}g}" @@ -162,11 +193,12 @@ def render_profile_csv( label: str, decimals: int, fixed: bool = True, + as_float: bool = False, ) -> str: """Render a strategy or behavior profile as a single comma-separated line, tagged with `label`, matching `MixedStrategyProfileCSVRenderer` and `MixedBehaviorProfileCSVRenderer` in the C++ tools. See `format_value` for - the meaning of `fixed`. + the meaning of `fixed` and `as_float`. """ if _is_behavior_profile(profile): values = [ @@ -179,7 +211,36 @@ def render_profile_csv( values = [ prob for player in profile.game.players for _label, prob in profile[player.label] ] - return ",".join([label, *(format_value(v, decimals, fixed) for v in values)]) + return ",".join([label, *(format_value(v, decimals, fixed, as_float) for v in values)]) + + +def render_support_csv( + support: gbt.StrategySupportProfile | gbt.BehaviorSupportProfile, + label: str, +) -> str: + """Render a strategy or behavior support profile as a single comma-separated line, + tagged with `label`: one field per player (strategy support) or per information set + (behavior support), each field a string of `1`/`0` digits marking membership in the + support, matching `gambit-enumpoly`'s own `PrintSupport`. + """ + if isinstance(support, gbt.BehaviorSupportProfile): + fields = [ + "".join( + "1" if action.label in action_support else "0" + for action in action_support.infoset.actions + ) + for player in support.game.players + for action_support in support[player.label] + ] + else: + fields = [ + "".join( + "1" if strategy.label in support[player.label] else "0" + for strategy in player.strategies + ) + for player in support.game.players + ] + return ",".join([label, *fields]) def render_profile_detail( @@ -252,3 +313,58 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> ) lines.append("") return "\n".join(lines) + + +def read_strategy_profiles_csv( + path: str, + game: gbt.Game, + rational: bool = False, +) -> list[gbt.MixedStrategyProfile]: + """Read one mixed strategy profile per line from `path`, each a flat comma-separated + list of probabilities in the same order as a profile's CSV row (excluding any + leading label), matching the C++ tools' `ReadStrategyProfiles`/`ReadProfiles`. + """ + convert = gbt.Rational if rational else float + strategies = [strategy for player in game.players for strategy in player.strategies] + profiles = [] + for line in pathlib.Path(path).read_text().splitlines(): + line = line.strip() + if not line: + continue + fields = line.split(",") + try: + values = iter([convert(fields[i]) for i in range(len(strategies))]) + except (ValueError, IndexError) as exc: + raise ValueError(f"Error reading strategy profile from '{path}': {exc}") from None + profile = game.mixed_strategy_profile(rational=rational) + for player in game.players: + profile[player.label] = {s.label: next(values) for s in player.strategies} + profiles.append(profile) + return profiles + + +def read_behavior_profiles_csv(path: str, game: gbt.Game) -> list[gbt.MixedBehaviorProfileDouble]: + """Read one mixed behavior profile per line from `path`, each a flat comma-separated + list of probabilities in the same order as a profile's CSV row (excluding any + leading label), matching the C++ tools' `ReadBehaviorProfiles`. + """ + count = sum( + len(list(infoset.actions)) for player in game.players for infoset in player.infosets + ) + profiles = [] + for line in pathlib.Path(path).read_text().splitlines(): + line = line.strip() + if not line: + continue + fields = line.split(",") + try: + values = iter([float(fields[i]) for i in range(count)]) + except (ValueError, IndexError) as exc: + raise ValueError(f"Error reading behavior profile from '{path}': {exc}") from None + profile = game.mixed_behavior_profile() + for player in game.players: + for infoset in player.infosets: + node = next(iter(infoset.members)) + profile[node] = {a.label: next(values) for a in infoset.actions} + profiles.append(profile) + return profiles diff --git a/src/pygambit/cli/enumpoly.py b/src/pygambit/cli/enumpoly.py new file mode 100644 index 0000000000..42b0ce7901 --- /dev/null +++ b/src/pygambit/cli/enumpoly.py @@ -0,0 +1,147 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/enumpoly.py +# Command-line driver program for Nash equilibrium enumeration via support +# enumeration and polynomial system solving +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for Nash equilibrium enumeration via support +enumeration and polynomial system solving. + +.. note:: + + Solving via PHCpack (the C++ tool's ``phcpack_path`` equivalent has no CLI + option) is not available here; see :func:`pygambit.nash.enumpoly_solve`'s + `phcpack_path` parameter for the equivalent computation from Python. +""" + +from __future__ import annotations + +import click + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + render_profile_csv, + render_support_csv, + validate_stop_after, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria by solving polynomial systems" +PROG_NAME = "gambit-enumpoly" + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified. " + "With no options, reports all Nash equilibria found." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-d", + "decimals", + default=6, + show_default=True, + type=int, + help="show equilibrium probabilities with DECIMALS digits", +) +@click.option("-S", "--strategic", is_flag=True, help="use strategic game") +@click.option( + "-m", + "maxregret", + default=1.0e-8, + show_default=True, + type=float, + help="maximum regret acceptable as a proportion of the range of payoffs in the game", +) +@click.option( + "-e", + "stop_after", + type=str, + default=None, + callback=validate_stop_after, + help="terminate after finding EQA equilibria (default is to search in all supports)", +) +@click.option( + "-r", + "max_rectangles", + default=20_000, + show_default=True, + type=int, + help="maximum number of rectangles to examine when searching for roots on a " + "single support, before giving up on that support", +) +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@click.option( + "-V", + "--verbose", + is_flag=True, + help="verbose mode (shows supports investigated); default is only to show equilibria", +) +@version_option(DESCRIPTION) +@handle_errors +def main( + file: str | None, + decimals: int, + strategic: bool, + maxregret: float, + stop_after: int | None, + max_rectangles: int, + quiet: bool, + verbose: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION) + game = read_game(open_game_file(file, PROG_NAME)) + if not game.is_perfect_recall: + raise ValueError("Computing equilibria of games with imperfect recall is not supported.") + + def render(profile) -> None: + click.echo(render_profile_csv(profile, "NE", decimals)) + + def render_event(event) -> None: + if not verbose: + return + if isinstance(event, gbt.EnumPolyCandidateSupportEvent): + click.echo(render_support_csv(event.support, "candidate")) + elif isinstance(event, gbt.EnumPolySingularSupportEvent): + click.echo(render_support_csv(event.support, "singular")) + elif isinstance(event, gbt.EnumPolyBudgetExceededSupportEvent): + click.echo(render_support_csv(event.support, "budget-exceeded")) + + gbt.nash.enumpoly_solve( + game, + use_strategic=strategic, + stop_after=stop_after, + maxregret=maxregret, + max_rectangles=max_rectangles, + nash_callback=render, + event_callback=render_event, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/cli/gnm.py b/src/pygambit/cli/gnm.py new file mode 100644 index 0000000000..1100c8b999 --- /dev/null +++ b/src/pygambit/cli/gnm.py @@ -0,0 +1,192 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/gnm.py +# Command-line driver program for Nash equilibrium computation via a global +# Newton method +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for Nash equilibrium computation via a global +Newton method. +""" + +from __future__ import annotations + +import click +import numpy as np + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + read_strategy_profiles_csv, + render_profile_csv, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria using a global Newton method" +PROG_NAME = "gambit-gnm" +_EXTRA_BANNER = ("Gametracer version 0.2, Copyright (C) 2002, Ben Blum and Christian Shelton",) + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-d", + "decimals", + default=6, + show_default=True, + type=int, + help="show equilibria as floating point with DECIMALS digits", +) +@click.option( + "-n", + "n_vectors", + type=int, + default=None, + help="number of perturbation vectors to generate randomly (mutually exclusive with -s)", +) +@click.option( + "-R", + "seed", + type=int, + default=None, + help="seed the random number generator used to generate perturbation vectors " + "(default is to seed from system entropy); requires -n", +) +@click.option( + "-s", + "start_file", + type=str, + default=None, + help="file containing perturbation vectors (mutually exclusive with -n)", +) +@click.option( + "-m", + "end_lambda", + default=-10.0, + show_default=True, + type=float, + help="lambda to end tracing (must be negative)", +) +@click.option( + "-f", + "local_newton_interval", + default=3, + show_default=True, + type=int, + help="frequency to run local Newton method", +) +@click.option( + "-i", + "local_newton_maxits", + default=10, + show_default=True, + type=int, + help="maximum iterations in local Newton", +) +@click.option( + "-c", + "steps", + default=100, + show_default=True, + type=int, + help="number of steps in each support cell", +) +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@click.option( + "-V", + "--verbose", + is_flag=True, + help="verbose mode (shows intermediate output)", +) +@version_option(DESCRIPTION, _EXTRA_BANNER) +@handle_errors +def main( + file: str | None, + decimals: int, + n_vectors: int | None, + seed: int | None, + start_file: str | None, + end_lambda: float, + local_newton_interval: int, + local_newton_maxits: int, + steps: int, + quiet: bool, + verbose: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION, _EXTRA_BANNER) + if end_lambda >= 0.0: + raise ValueError("Value for -m (end lambda) must be negative") + if local_newton_interval <= 0: + raise ValueError("Value for -f (local Newton frequency) must be at least 1") + if local_newton_maxits <= 0: + raise ValueError("Value for -i (local Newton iterations) must be at least 1") + if steps <= 0: + raise ValueError("Value for -c (steps in support cell) must be at least 1") + if n_vectors is not None and start_file is not None: + raise ValueError("The -n and -s options are mutually exclusive.") + if seed is not None and n_vectors is None: + raise ValueError("The -R option requires -n.") + game = read_game(open_game_file(file, PROG_NAME)) + + if start_file is not None: + perturbations = read_strategy_profiles_csv(start_file, game) + else: + gen = np.random.default_rng(seed) + perturbations = [ + game.random_strategy_profile(gen=gen) + for _ in range(n_vectors if n_vectors is not None else 1) + ] + + def render(profile, label: str = "NE") -> None: + click.echo(render_profile_csv(profile, label, decimals)) + + def render_event(event) -> None: + if not verbose: + return + if isinstance(event, gbt.GNMPerturbationEvent): + render(event.profile, "pert") + elif isinstance(event, gbt.GNMStartEvent): + render(event.profile, "start") + elif isinstance(event, gbt.GNMStepEvent): + render(event.profile, f"{event.lam:.6g}") + + for perturbation in perturbations: + gbt.nash.gnm_solve( + perturbation, + end_lambda=end_lambda, + steps=steps, + local_newton_interval=local_newton_interval, + local_newton_maxits=local_newton_maxits, + nash_callback=render, + event_callback=render_event, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/cli/ipa.py b/src/pygambit/cli/ipa.py new file mode 100644 index 0000000000..9a49d5483d --- /dev/null +++ b/src/pygambit/cli/ipa.py @@ -0,0 +1,140 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/ipa.py +# Command-line driver program for Nash equilibrium computation via iterated +# polymatrix approximation +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for Nash equilibrium computation via iterated +polymatrix approximation. +""" + +from __future__ import annotations + +import click +import numpy as np + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + read_strategy_profiles_csv, + render_profile_csv, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria using iterated polymatrix approximation" +PROG_NAME = "gambit-ipa" +_EXTRA_BANNER = ("Gametracer version 0.2, Copyright (C) 2002, Ben Blum and Christian Shelton",) + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-d", + "decimals", + default=6, + show_default=True, + type=int, + help="show equilibria as floating point with DECIMALS digits", +) +@click.option( + "-n", + "n_vectors", + type=int, + default=None, + help="number of perturbation vectors to generate randomly (mutually exclusive with -s)", +) +@click.option( + "-R", + "seed", + type=int, + default=None, + help="seed the random number generator used to generate perturbation vectors " + "(default is to seed from system entropy); requires -n", +) +@click.option( + "-s", + "start_file", + type=str, + default=None, + help="file containing perturbation vectors (mutually exclusive with -n)", +) +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@click.option( + "-V", + "--verbose", + is_flag=True, + help="verbose mode (shows intermediate output)", +) +@version_option(DESCRIPTION, _EXTRA_BANNER) +@handle_errors +def main( + file: str | None, + decimals: int, + n_vectors: int | None, + seed: int | None, + start_file: str | None, + quiet: bool, + verbose: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION, _EXTRA_BANNER) + if n_vectors is not None and start_file is not None: + raise ValueError("The -n and -s options are mutually exclusive.") + if seed is not None and n_vectors is None: + raise ValueError("The -R option requires -n.") + game = read_game(open_game_file(file, PROG_NAME)) + + if start_file is not None: + perturbations = read_strategy_profiles_csv(start_file, game) + else: + gen = np.random.default_rng(seed) + perturbations = [ + game.random_strategy_profile(gen=gen) + for _ in range(n_vectors if n_vectors is not None else 1) + ] + + def render(profile, label: str = "NE") -> None: + click.echo(render_profile_csv(profile, label, decimals)) + + def render_event(event) -> None: + if not verbose: + return + if isinstance(event, gbt.IPAStepEvent): + render(event.profile, f"iter-{event.iteration}") + + for perturbation in perturbations: + gbt.nash.ipa_solve( + perturbation, + nash_callback=render, + event_callback=render_event, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/cli/lcp.py b/src/pygambit/cli/lcp.py index 854a68aceb..e84c551fdb 100644 --- a/src/pygambit/cli/lcp.py +++ b/src/pygambit/cli/lcp.py @@ -25,8 +25,6 @@ from __future__ import annotations -import sys - import click import pygambit as gbt @@ -38,6 +36,7 @@ read_game, render_profile_csv, render_profile_detail, + validate_stop_after, version_option, ) @@ -45,17 +44,6 @@ PROG_NAME = "gambit-lcp" -def _validate_stop_after( - ctx: click.Context, param: click.Parameter, value: str | None -) -> int | None: - if value is None: - return None - if not value.isdigit() or int(value) == 0: - click.echo(f"Error: -e argument must be a positive integer; got '{value}'.", err=True) - sys.exit(1) - return int(value) - - @click.command( context_settings={"help_option_names": ["-h", "--help"]}, help=( @@ -80,7 +68,7 @@ def _validate_stop_after( "stop_after", type=str, default=None, - callback=_validate_stop_after, + callback=validate_stop_after, help="terminate after finding EQA equilibria " "(strategic games only; default is to find all accessible equilibria)", ) diff --git a/src/pygambit/cli/liap.py b/src/pygambit/cli/liap.py new file mode 100644 index 0000000000..1c75a5bb85 --- /dev/null +++ b/src/pygambit/cli/liap.py @@ -0,0 +1,186 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/liap.py +# Command-line driver program for Nash equilibrium computation via +# Lyapunov function minimization +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for Nash equilibrium computation via Lyapunov +function minimization. +""" + +from __future__ import annotations + +import click +import numpy as np + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_behavior_profiles_csv, + read_game, + read_strategy_profiles_csv, + render_profile_csv, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria by minimizing the Lyapunov function" +PROG_NAME = "gambit-liap" + +_DEFAULT_TRIES = 10 + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified. " + "With no options, attempts to compute an equilibrium from randomly " + "generated starting points." + ), +) +@click.argument("file", required=False, default=None) +@click.option("-A", "--agent", is_flag=True, help="compute agent form equilibria") +@click.option( + "-d", + "decimals", + default=6, + show_default=True, + type=int, + help="print probabilities with DECIMALS digits", +) +@click.option( + "-n", + "n_tries", + type=int, + default=None, + help="number of starting points to generate randomly (mutually exclusive with -s)", +) +@click.option( + "-R", + "seed", + type=int, + default=None, + help="seed the random number generator used to generate starting points " + "(default is to seed from system entropy); requires -n", +) +@click.option( + "-i", + "maxiter", + default=1000, + show_default=True, + type=int, + help="maximum number of iterations per point", +) +@click.option( + "-m", + "maxregret", + default=1.0e-4, + show_default=True, + type=float, + help="maximum regret acceptable as a proportion of the range of payoffs in the game", +) +@click.option( + "-s", + "start_file", + type=str, + default=None, + help="file containing starting points (mutually exclusive with -n)", +) +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@click.option( + "-V", + "--verbose", + is_flag=True, + help="verbose mode (shows intermediate output); default is to only show equilibria", +) +@version_option(DESCRIPTION) +@handle_errors +def main( + file: str | None, + agent: bool, + decimals: int, + n_tries: int | None, + seed: int | None, + maxiter: int, + maxregret: float, + start_file: str | None, + quiet: bool, + verbose: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION) + if n_tries is not None and start_file is not None: + raise ValueError("The -n and -s options are mutually exclusive.") + if seed is not None and n_tries is None: + raise ValueError("The -R option requires -n.") + game = read_game(open_game_file(file, PROG_NAME)) + use_agent = agent and game.is_tree + + def render(profile, label: str = "NE") -> None: + click.echo(render_profile_csv(profile, label, decimals)) + + def render_event(event) -> None: + if not verbose: + return + if isinstance(event, gbt.LiapStartEvent): + render(event.profile, "start") + elif isinstance(event, gbt.LiapEndEvent): + render(event.profile, "end") + + if use_agent: + starts = ( + read_behavior_profiles_csv(start_file, game) + if start_file is not None + else [ + game.random_behavior_profile(gen=np.random.default_rng(seed)) + for _ in range(n_tries if n_tries is not None else _DEFAULT_TRIES) + ] + ) + for start in starts: + gbt.nash.liap_agent_solve( + start, + maxregret=maxregret, + maxiter=maxiter, + nash_callback=render, + event_callback=render_event, + ) + else: + starts = ( + read_strategy_profiles_csv(start_file, game) + if start_file is not None + else [ + game.random_strategy_profile(gen=np.random.default_rng(seed)) + for _ in range(n_tries if n_tries is not None else _DEFAULT_TRIES) + ] + ) + for start in starts: + gbt.nash.liap_solve( + start, + maxregret=maxregret, + maxiter=maxiter, + nash_callback=render, + event_callback=render_event, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/cli/simpdiv.py b/src/pygambit/cli/simpdiv.py new file mode 100644 index 0000000000..67b48a5dd6 --- /dev/null +++ b/src/pygambit/cli/simpdiv.py @@ -0,0 +1,185 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/simpdiv.py +# Command-line driver program for Nash equilibrium computation via +# simplicial subdivision +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +"""Command-line driver program for Nash equilibrium computation via simplicial +subdivision. +""" + +from __future__ import annotations + +import click +import numpy as np + +import pygambit as gbt + +from .common import ( + handle_errors, + open_game_file, + print_banner, + read_game, + read_strategy_profiles_csv, + render_profile_csv, + version_option, +) + +DESCRIPTION = "Compute Nash equilibria using simplicial subdivision" +PROG_NAME = "gambit-simpdiv" + + +def _default_start(game: gbt.Game) -> gbt.MixedStrategyProfileRational: + """Each player's first strategy, matching the C++ library's `SimpdivDefaultStart`.""" + start = game.mixed_strategy_profile(rational=True) + for player in game.players: + first_strategy = next(iter(player.strategies)) + start[player.label] = {first_strategy.label: 1} + return start + + +@click.command( + context_settings={"help_option_names": ["-h", "--help"]}, + help=( + f"{DESCRIPTION}.\n\n" + "Reads a game from FILE, or from standard input if FILE is not specified. " + "With no options, computes one approximate Nash equilibrium." + ), +) +@click.argument("file", required=False, default=None) +@click.option( + "-g", + "refine", + default=2, + show_default=True, + type=int, + help="granularity of grid refinement at each step", +) +@click.option( + "-r", + "rand_denom", + type=int, + default=None, + help="generate random starting points with denominator DENOM (mutually exclusive with -s)", +) +@click.option( + "-n", + "n_tries", + type=int, + default=None, + help="number of starting points to generate (requires -r)", +) +@click.option( + "-R", + "seed", + type=int, + default=None, + help="seed the random number generator used to generate starting points " + "(default is to seed from system entropy); requires -n", +) +@click.option( + "-s", + "start_file", + type=str, + default=None, + help="file containing starting points (mutually exclusive with -r)", +) +@click.option( + "-d", + "decimals", + default=0, + show_default=True, + type=int, + help="show profiles as floating point with DECIMALS digits " + "(default is to display rational numbers)", +) +@click.option( + "-m", + "maxregret", + type=str, + default=None, + help="maximum regret acceptable as a proportion of range of payoffs in the game " + "(default is 1/10000000)", +) +@click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") +@click.option( + "-V", + "--verbose", + is_flag=True, + help="verbose mode (shows intermediate output); default is to only show equilibria", +) +@version_option(DESCRIPTION) +@handle_errors +def main( + file: str | None, + refine: int, + rand_denom: int | None, + n_tries: int | None, + seed: int | None, + start_file: str | None, + decimals: int, + maxregret: str | None, + quiet: bool, + verbose: bool, +) -> None: + if not quiet: + print_banner(DESCRIPTION) + if rand_denom is not None and start_file is not None: + raise ValueError("The -r and -s options are mutually exclusive.") + if n_tries is not None and rand_denom is None: + raise ValueError("The -n option requires -r.") + if seed is not None and n_tries is None: + raise ValueError("The -R option requires -n.") + game = read_game(open_game_file(file, PROG_NAME)) + maxregret_value = gbt.Rational(maxregret) if maxregret is not None else None + as_float = decimals > 0 + + if start_file is not None: + starts = read_strategy_profiles_csv(start_file, game, rational=True) + elif rand_denom is not None: + starts = [ + game.random_strategy_profile(denom=rand_denom, gen=np.random.default_rng(seed)) + for _ in range(n_tries if n_tries is not None else 1) + ] + else: + starts = [_default_start(game)] + + def render(profile, label: str = "NE") -> None: + click.echo(render_profile_csv(profile, label, decimals, as_float=as_float)) + + def render_event(event) -> None: + if not verbose: + return + if isinstance(event, gbt.SimpdivStartEvent): + render(event.profile, "start") + elif isinstance(event, gbt.SimpdivRefinementEvent): + render(event.profile, f"{float(event.grid_size):.6f}") + + for start in starts: + gbt.nash.simpdiv_solve( + start, + maxregret=maxregret_value, + refine=refine, + nash_callback=render, + event_callback=render_event, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index eaff807af8..0c4082f6f7 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -273,7 +273,7 @@ def _estimate_behavior_empirical( for player in data.game.players: for infoset in player.infosets: node = next(iter(infoset.members)) - profile[node] = [math.exp(next(log_probs)) for _ in infoset.actions] + profile[node] = {a.label: math.exp(next(log_probs)) for a in infoset.actions} return LogitQREMixedBehaviorFitResult( data, "empirical", res.x[0], profile, -res.fun ) From f7ed83af638ba0b2f7f0c2224a68386c5399506d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 26 Aug 2026 14:00:49 +0100 Subject: [PATCH 04/11] Add tests for CLIs --- tests/cli/__init__.py | 0 tests/cli/conftest.py | 109 ++++++++++++++++++ tests/cli/test_common.py | 171 +++++++++++++++++++++++++++++ tests/cli/test_contract.py | 101 +++++++++++++++++ tests/cli/test_enummixed.py | 43 ++++++++ tests/cli/test_enumpoly.py | 76 +++++++++++++ tests/cli/test_enumpure.py | 107 ++++++++++++++++++ tests/cli/test_gnm.py | 115 +++++++++++++++++++ tests/cli/test_ipa.py | 74 +++++++++++++ tests/cli/test_lcp.py | 71 ++++++++++++ tests/cli/test_liap.py | 99 +++++++++++++++++ tests/cli/test_logit.py | 119 ++++++++++++++++++++ tests/cli/test_lp.py | 43 ++++++++ tests/cli/test_simpdiv.py | 71 ++++++++++++ tests/cli/test_smoke.py | 74 +++++++++++++ tests/test_behavspt_profiles.py | 189 ++++++++++++++++++++++++++++++++ 16 files changed, 1462 insertions(+) create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/conftest.py create mode 100644 tests/cli/test_common.py create mode 100644 tests/cli/test_contract.py create mode 100644 tests/cli/test_enummixed.py create mode 100644 tests/cli/test_enumpoly.py create mode 100644 tests/cli/test_enumpure.py create mode 100644 tests/cli/test_gnm.py create mode 100644 tests/cli/test_ipa.py create mode 100644 tests/cli/test_lcp.py create mode 100644 tests/cli/test_liap.py create mode 100644 tests/cli/test_logit.py create mode 100644 tests/cli/test_lp.py create mode 100644 tests/cli/test_simpdiv.py create mode 100644 tests/cli/test_smoke.py create mode 100644 tests/test_behavspt_profiles.py diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000000..7c2cfa5b43 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,109 @@ +"""Fixtures for testing the Python CLI tools (src/pygambit/cli/).""" + +import pytest +from click.testing import CliRunner + +import pygambit as gbt + +from ..games import create_2x2_zero_sum_efg + + +@pytest.fixture +def cli_runner() -> CliRunner: + """A `click.testing.CliRunner` for invoking CLI tools' `main` in-process.""" + return CliRunner() + + +def _table_game(payoffs: dict, title: str) -> gbt.Game: + game = gbt.Game.new_table([2, 2]) + game.title = title + p1, p2 = game.players + s1a, s1b = p1.strategies + s2a, s2b = p2.strategies + strategies = {"a": s1a, "b": s1b, "A": s2a, "B": s2b} + for (row, col), (v1, v2) in payoffs.items(): + outcome = game[strategies[row], strategies[col]] + outcome[p1] = v1 + outcome[p2] = v2 + return game + + +@pytest.fixture +def nfg_matching_pennies_text() -> str: + """A 2x2 constant-sum table game with a unique, fully mixed equilibrium, and no + pure-strategy equilibria. Suitable for tools that require a constant-sum game + (e.g. `gambit-lp`), or where an empty pure-equilibrium result is expected. + """ + game = _table_game( + { + ("a", "A"): (1, -1), ("a", "B"): (-1, 1), + ("b", "A"): (-1, 1), ("b", "B"): (1, -1), + }, + "Matching Pennies", + ) + return game.to_nfg() + + +@pytest.fixture +def nfg_coordination_text() -> str: + """A 2x2 table game with two pure-strategy equilibria and one mixed equilibrium.""" + game = _table_game( + { + ("a", "A"): (1, 1), ("a", "B"): (0, 0), + ("b", "A"): (0, 0), ("b", "B"): (1, 1), + }, + "Coordination", + ) + return game.to_nfg() + + +@pytest.fixture +def nfg_asymmetric_table_text() -> str: + """A 2x2 coordination-style table game whose payoffs aren't symmetric across + players or strategies, so that a QRE branch traced from the centroid takes many + steps to converge (unlike `nfg_coordination_text`/`nfg_matching_pennies_text`, + whose symmetry lets the trace converge in 1-2 points) -- useful for exercising + `gambit-logit`/`gambit-gnm`/`gambit-ipa`'s streamed intermediate output. + """ + game = _table_game( + { + ("a", "A"): (3, 1), ("a", "B"): (0, 0), + ("b", "A"): (0, 0), ("b", "B"): (1, 3), + }, + "Asymmetric coordination", + ) + return game.to_nfg() + + +@pytest.fixture +def efg_small_tree_text() -> str: + """A small 2-player zero-sum extensive-form game, with a unique fully mixed + equilibrium and no pure-strategy equilibria. + """ + return create_2x2_zero_sum_efg().to_efg() + + +@pytest.fixture +def efg_asymmetric_tree_text() -> str: + """A 2-player extensive-form game where player 1 has one information set (2 + actions) and player 2 has two information sets (3 and 2 actions), so that its + behavior-form profile (2 + 3 + 2 = 7 values) and reduced-strategic-form profile + (2 + 3*2 = 8 values) have different lengths -- useful for confirming that a + -S/-A-style switch actually changes which representation is reported, rather + than just checking the tool doesn't crash. + + Has several pure-strategy equilibria (all agreeing that player 1 plays "L" and + player 2 plays "x" after it; player 2's action after the off-path "R" is + payoff-irrelevant and so free to vary across equilibria). + """ + game = gbt.Game.new_tree(players=["1", "2"], title="Asymmetric multi-infoset game") + game.append_move(game.root, "1", ["L", "R"]) + left, right = game.root.children + game.append_move(left, "2", ["x", "y", "z"]) + game.append_move(right, "2", ["p", "q"]) + for node in left.children: + payoff = [1, 1] if node.prior_action.label == "x" else [0, 0] + game.set_outcome(node, game.add_outcome(node.prior_action.label, payoff)) + for node in right.children: + game.set_outcome(node, game.add_outcome(node.prior_action.label, [0, 0])) + return game.to_efg() diff --git a/tests/cli/test_common.py b/tests/cli/test_common.py new file mode 100644 index 0000000000..ae6070034f --- /dev/null +++ b/tests/cli/test_common.py @@ -0,0 +1,171 @@ +"""Unit tests for the shared infrastructure in src/pygambit/cli/common.py.""" + +import io +import sys + +import pytest + +import pygambit as gbt +from pygambit.cli import common + + +class TestReadGame: + def test_reads_nfg(self, nfg_matching_pennies_text): + game = common.read_game(io.BytesIO(nfg_matching_pennies_text.encode())) + assert not game.is_tree + assert len(game.players) == 2 + + def test_reads_efg(self, efg_small_tree_text): + game = common.read_game(io.BytesIO(efg_small_tree_text.encode())) + assert game.is_tree + assert len(game.players) == 2 + + def test_reads_text_stream(self, nfg_matching_pennies_text): + game = common.read_game(io.StringIO(nfg_matching_pennies_text)) + assert len(game.players) == 2 + + def test_empty_input_raises(self): + with pytest.raises(ValueError, match="Empty file or string provided"): + common.read_game(io.BytesIO(b"")) + + def test_whitespace_only_input_raises(self): + with pytest.raises(ValueError, match="Empty file or string provided"): + common.read_game(io.BytesIO(b" \n ")) + + def test_unrecognized_format_raises(self): + with pytest.raises(ValueError, match="Unrecognized file format"): + common.read_game(io.BytesIO(b"this is not a game file")) + + def test_malformed_nfg_raises_parse_error(self): + with pytest.raises(ValueError, match="Parse error in game file"): + common.read_game(io.BytesIO(b'NFG 1 R "broken" { "1" "2" }\n\n{ garbage')) + + +class TestOpenGameFile: + def test_none_returns_stdin(self): + assert common.open_game_file(None, "prog") is sys.stdin.buffer + + def test_dash_returns_stdin(self): + assert common.open_game_file("-", "prog") is sys.stdin.buffer + + def test_valid_path_is_opened(self, tmp_path): + path = tmp_path / "game.nfg" + path.write_text("NFG 1 R\n") + f = common.open_game_file(str(path), "prog") + try: + assert f.read() == b"NFG 1 R\n" + finally: + f.close() + + def test_missing_file_exits_1_with_perror_style_message(self, tmp_path, capsys): + missing = tmp_path / "does_not_exist.nfg" + with pytest.raises(SystemExit) as excinfo: + common.open_game_file(str(missing), "gambit-enumpure") + assert excinfo.value.code == 1 + captured = capsys.readouterr() + assert captured.err == f"gambit-enumpure: {missing}: No such file or directory\n" + + +class TestValidateStopAfter: + def test_none_passes_through(self): + assert common.validate_stop_after(None, None, None) is None + + def test_valid_positive_integer(self): + assert common.validate_stop_after(None, None, "5") == 5 + + def test_zero_is_rejected(self, capsys): + with pytest.raises(SystemExit) as excinfo: + common.validate_stop_after(None, None, "0") + assert excinfo.value.code == 1 + assert "must be a positive integer; got '0'." in capsys.readouterr().err + + def test_non_numeric_is_rejected(self, capsys): + with pytest.raises(SystemExit) as excinfo: + common.validate_stop_after(None, None, "abc") + assert excinfo.value.code == 1 + assert "must be a positive integer; got 'abc'." in capsys.readouterr().err + + def test_negative_is_rejected(self): + with pytest.raises(SystemExit): + common.validate_stop_after(None, None, "-3") + + +class TestHandleErrors: + def test_success_passes_through(self, capsys): + @common.handle_errors + def f(): + print("ok") + + f() + assert capsys.readouterr().out == "ok\n" + + @pytest.mark.parametrize("exc_type", [ValueError, RuntimeError, OSError]) + def test_known_exceptions_become_error_message_and_exit_1(self, capsys, exc_type): + @common.handle_errors + def f(): + raise exc_type("something went wrong") + + with pytest.raises(SystemExit) as excinfo: + f() + assert excinfo.value.code == 1 + assert capsys.readouterr().err == "Error: something went wrong\n" + + def test_unrelated_exception_propagates(self): + @common.handle_errors + def f(): + raise KeyError("not handled") + + with pytest.raises(KeyError): + f() + + +class TestFormatValue: + def test_rational_ignores_decimals_by_default(self): + assert common.format_value(gbt.Rational(1, 3), 6) == "1/3" + + def test_rational_whole_number(self): + assert common.format_value(gbt.Rational(1, 1), 0) == "1" + + def test_float_fixed(self): + assert common.format_value(0.5, 3) == "0.500" + + def test_float_general_trims_trailing_zeroes(self): + assert common.format_value(0.5, 6, fixed=False) == "0.5" + + def test_float_general_switches_to_scientific(self): + assert common.format_value(1.38594e-08, 6, fixed=False) == "1.38594e-08" + + def test_rational_as_float_converts_and_formats_fixed(self): + assert common.format_value(gbt.Rational(1, 3), 3, as_float=True) == "0.333" + + +class TestRenderProfileCsv: + def test_strategy_profile(self, nfg_matching_pennies_text): + game = gbt.read_nfg(io.BytesIO(nfg_matching_pennies_text.encode())) + profile = game.mixed_strategy_profile(rational=True) + assert common.render_profile_csv(profile, "NE", 0) == "NE,1/2,1/2,1/2,1/2" + + def test_behavior_profile(self, efg_small_tree_text): + game = gbt.read_efg(io.BytesIO(efg_small_tree_text.encode())) + profile = game.mixed_behavior_profile(rational=True) + assert common.render_profile_csv(profile, "start", 0) == "start,1/2,1/2,1/2,1/2" + + def test_label_is_first_field(self, nfg_matching_pennies_text): + game = gbt.read_nfg(io.BytesIO(nfg_matching_pennies_text.encode())) + profile = game.mixed_strategy_profile(rational=True) + assert common.render_profile_csv(profile, "custom-label", 0).startswith("custom-label,") + + +class TestRenderSupportCsv: + def test_strategy_support_full_support(self, nfg_matching_pennies_text): + game = gbt.read_nfg(io.BytesIO(nfg_matching_pennies_text.encode())) + support = game.strategy_support_profile() + assert common.render_support_csv(support, "candidate") == "candidate,11,11" + + def test_strategy_support_partial_support(self, nfg_matching_pennies_text): + game = gbt.read_nfg(io.BytesIO(nfg_matching_pennies_text.encode())) + support = game.strategy_support_profile() + p1 = next(iter(game.players)) + first_strategy = next(iter(p1.strategies)) + support[p1.label] = [first_strategy.label] + assert common.render_support_csv(support, "candidate") == "candidate,10,11" diff --git a/tests/cli/test_contract.py b/tests/cli/test_contract.py new file mode 100644 index 0000000000..f836ebc50c --- /dev/null +++ b/tests/cli/test_contract.py @@ -0,0 +1,101 @@ +"""Tests for behavior every Gambit CLI tool is supposed to share: the banner, +``-q``/``-h``/``-v``, reading a game from a file or from standard input, and +reporting a missing file the way the C++ tools' ``perror()`` call does. + +Each tool's tool-specific options are tested in their own test module. +""" + +import pytest + +from pygambit.cli import ( + enummixed, + enumpoly, + enumpure, + gnm, + ipa, + lcp, + liap, + logit, + lp, + simpdiv, +) + +TOOLS = [ + enumpure, + logit, + enummixed, + lcp, + lp, + liap, + simpdiv, + gnm, + ipa, + enumpoly, +] + + +@pytest.fixture(params=TOOLS, ids=[t.PROG_NAME for t in TOOLS]) +def tool(request): + return request.param + + +def test_quiet_suppresses_banner(cli_runner, tool, nfg_matching_pennies_text): + result = cli_runner.invoke(tool.main, ["-q"], input=nfg_matching_pennies_text) + assert tool.DESCRIPTION not in result.stderr + assert result.exit_code == 0 + + +def test_banner_shown_by_default(cli_runner, tool, nfg_matching_pennies_text): + result = cli_runner.invoke(tool.main, [], input=nfg_matching_pennies_text) + assert tool.DESCRIPTION in result.stderr + assert "Copyright" in result.stderr + assert result.exit_code == 0 + + +def test_version_prints_banner_and_exits_without_reading_a_game(cli_runner, tool): + result = cli_runner.invoke(tool.main, ["-v"]) + assert result.exit_code == 0 + assert tool.DESCRIPTION in result.stderr + assert result.stdout == "" + + +def test_help_exits_zero(cli_runner, tool): + result = cli_runner.invoke(tool.main, ["-h"]) + assert result.exit_code == 0 + result = cli_runner.invoke(tool.main, ["--help"]) + assert result.exit_code == 0 + + +def test_missing_file_exits_1_and_names_the_program(cli_runner, tool, tmp_path): + missing = tmp_path / "does_not_exist.nfg" + result = cli_runner.invoke(tool.main, ["-q", str(missing)]) + assert result.exit_code == 1 + assert result.stderr == f"{tool.PROG_NAME}: {missing}: No such file or directory\n" + assert result.stdout == "" + + +def test_reads_game_from_stdin_when_no_file_given(cli_runner, tool, nfg_matching_pennies_text): + result = cli_runner.invoke(tool.main, ["-q"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + + +def test_reads_game_from_file_argument(cli_runner, tool, tmp_path, nfg_matching_pennies_text): + path = tmp_path / "game.nfg" + path.write_text(nfg_matching_pennies_text) + result = cli_runner.invoke(tool.main, ["-q", str(path)]) + assert result.exit_code == 0 + + +def test_empty_game_file_is_a_clean_error(cli_runner, tool, tmp_path): + path = tmp_path / "empty.nfg" + path.write_text("") + result = cli_runner.invoke(tool.main, ["-q", str(path)]) + assert result.exit_code == 1 + assert result.stderr == "Error: Empty file or string provided\n" + + +def test_unknown_option_is_a_usage_error(cli_runner, tool, nfg_matching_pennies_text): + result = cli_runner.invoke( + tool.main, ["--this-flag-does-not-exist"], input=nfg_matching_pennies_text + ) + assert result.exit_code != 0 diff --git a/tests/cli/test_enummixed.py b/tests/cli/test_enummixed.py new file mode 100644 index 0000000000..e9cb6c1753 --- /dev/null +++ b/tests/cli/test_enummixed.py @@ -0,0 +1,43 @@ +"""Tests that gambit-enummixed's switches produce the behavior they document.""" + +from pygambit.cli import enummixed + + +def test_default_output_is_exact_rational(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(enummixed.main, ["-q"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,1/2,1/2,1/2,1/2" + + +def test_decimals_flag_switches_to_floating_point(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(enummixed.main, ["-q", "-d", "3"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.500,0.500,0.500,0.500" + + +def test_decimals_digit_count_is_honored(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(enummixed.main, ["-q", "-d", "1"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.5,0.5,0.5,0.5" + + +def test_cliques_flag_appends_convex_labeled_lines(cli_runner, nfg_matching_pennies_text): + without = cli_runner.invoke(enummixed.main, ["-q"], input=nfg_matching_pennies_text) + with_cliques = cli_runner.invoke(enummixed.main, ["-q", "-c"], input=nfg_matching_pennies_text) + assert with_cliques.exit_code == 0 + assert with_cliques.stdout.startswith(without.stdout) + extra = with_cliques.stdout[len(without.stdout) :] + assert extra.strip().splitlines() == ["convex-1,1/2,1/2,1/2,1/2"] + + +def test_cliques_flag_numbers_each_equilibrium_class(cli_runner, nfg_coordination_text): + result = cli_runner.invoke(enummixed.main, ["-q", "-c"], input=nfg_coordination_text) + assert result.exit_code == 0 + labels = [line.split(",")[0] for line in result.stdout.strip().splitlines()] + assert labels == ["NE", "NE", "NE", "convex-1", "convex-2", "convex-3"] + + +def test_without_cliques_flag_no_convex_lines_appear(cli_runner, nfg_coordination_text): + result = cli_runner.invoke(enummixed.main, ["-q"], input=nfg_coordination_text) + assert result.exit_code == 0 + assert "convex" not in result.stdout diff --git a/tests/cli/test_enumpoly.py b/tests/cli/test_enumpoly.py new file mode 100644 index 0000000000..13acee8ca3 --- /dev/null +++ b/tests/cli/test_enumpoly.py @@ -0,0 +1,76 @@ +"""Tests that gambit-enumpoly's switches produce the behavior they document.""" + +import pathlib + +from pygambit.cli import enumpoly + + +def test_default_output(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(enumpoly.main, ["-q"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.500000,0.500000,0.500000,0.500000" + + +def test_decimals_flag_changes_precision(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(enumpoly.main, ["-q", "-d", "2"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.50,0.50,0.50,0.50" + + +def test_verbose_flag_adds_support_lines(cli_runner, nfg_coordination_text): + plain = cli_runner.invoke(enumpoly.main, ["-q"], input=nfg_coordination_text) + verbose = cli_runner.invoke(enumpoly.main, ["-q", "-V"], input=nfg_coordination_text) + assert plain.exit_code == 0 + assert verbose.exit_code == 0 + assert "candidate," not in plain.stdout + assert "candidate," in verbose.stdout + # every NE line in the plain run also appears in the verbose run + for line in plain.stdout.strip().splitlines(): + assert line in verbose.stdout + + +def test_verbose_support_lines_mark_which_strategies_are_active(cli_runner, nfg_coordination_text): + result = cli_runner.invoke(enumpoly.main, ["-q", "-V"], input=nfg_coordination_text) + assert result.exit_code == 0 + candidates = [line for line in result.stdout.splitlines() if line.startswith("candidate,")] + assert set(candidates) == {"candidate,10,10", "candidate,01,01", "candidate,11,11"} + + +def test_stop_after_limits_number_of_equilibria(cli_runner, nfg_coordination_text): + unlimited = cli_runner.invoke(enumpoly.main, ["-q"], input=nfg_coordination_text) + limited = cli_runner.invoke(enumpoly.main, ["-q", "-e", "1"], input=nfg_coordination_text) + assert unlimited.exit_code == 0 + assert limited.exit_code == 0 + assert len(unlimited.stdout.strip().splitlines()) > 1 + assert len(limited.stdout.strip().splitlines()) == 1 + + +def test_stop_after_rejects_non_positive_integer(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(enumpoly.main, ["-q", "-e", "abc"], input=nfg_matching_pennies_text) + assert result.exit_code == 1 + assert result.stderr == "Error: -e argument must be a positive integer; got 'abc'.\n" + + +def test_strategic_flag_switches_tree_game_to_strategy_representation( + cli_runner, efg_asymmetric_tree_text +): + behavior_result = cli_runner.invoke(enumpoly.main, ["-q"], input=efg_asymmetric_tree_text) + strategic_result = cli_runner.invoke( + enumpoly.main, ["-q", "-S"], input=efg_asymmetric_tree_text + ) + assert behavior_result.exit_code == 0 + assert strategic_result.exit_code == 0 + behavior_lines = behavior_result.stdout.strip().splitlines() + strategic_lines = strategic_result.stdout.strip().splitlines() + assert behavior_lines and strategic_lines + assert all(len(line.split(",")) == 8 for line in behavior_lines) + assert all(len(line.split(",")) == 9 for line in strategic_lines) + + +def test_imperfect_recall_is_rejected(cli_runner): + imperfect_recall_efg = pathlib.Path("tests/test_games/gilboa_two_am_agents.efg").read_text() + result = cli_runner.invoke(enumpoly.main, ["-q"], input=imperfect_recall_efg) + assert result.exit_code == 1 + assert result.stderr == ( + "Error: Computing equilibria of games with imperfect recall is not supported.\n" + ) diff --git a/tests/cli/test_enumpure.py b/tests/cli/test_enumpure.py new file mode 100644 index 0000000000..448111bf24 --- /dev/null +++ b/tests/cli/test_enumpure.py @@ -0,0 +1,107 @@ +"""Tests that gambit-enumpure's switches produce the behavior they document. + +Rendering (CSV vs -D detail) is checked end to end on real, tiny games. Which +representation gets solved -- the -A/-S/is_tree logic -- is checked by spying on +the pygambit.nash functions the tool is supposed to call, since that logic is +about argument wiring, not about the numerics of any particular game. +""" + +import pygambit as gbt +from pygambit.cli import enumpure + + +def test_default_finds_pure_equilibria(cli_runner, nfg_coordination_text): + result = cli_runner.invoke(enumpure.main, ["-q"], input=nfg_coordination_text) + assert result.exit_code == 0 + assert result.stdout.strip().splitlines() == ["NE,1,0,1,0", "NE,0,1,0,1"] + + +def test_no_pure_equilibria_is_empty_output(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(enumpure.main, ["-q"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout == "" + + +def test_detail_flag_switches_to_multiline_report(cli_runner, nfg_coordination_text): + csv_result = cli_runner.invoke(enumpure.main, ["-q"], input=nfg_coordination_text) + detail_result = cli_runner.invoke(enumpure.main, ["-q", "-D"], input=nfg_coordination_text) + assert detail_result.exit_code == 0 + assert "NE," not in detail_result.stdout + assert "Strategy profile for player 1:" in detail_result.stdout + assert "Strategy profile for player 2:" in detail_result.stdout + assert len(detail_result.stdout.splitlines()) > len(csv_result.stdout.splitlines()) + + +class TestRepresentationWiring: + """-A only takes effect on a tree game; -S only converts *display*, not which + solver runs. Verified by spying on the two entry points rather than needing a + game whose behavior/strategy representations happen to render differently. + """ + + def test_table_game_always_uses_strategy_solve( + self, cli_runner, monkeypatch, nfg_matching_pennies_text + ): + called = [] + monkeypatch.setattr( + gbt.nash, "enumpure_solve", lambda *a, **k: called.append("strategy") or None + ) + monkeypatch.setattr( + gbt.nash, "enumpure_agent_solve", lambda *a, **k: called.append("agent") or None + ) + for flags in ([], ["-A"], ["-S"], ["-A", "-S"]): + called.clear() + result = cli_runner.invoke( + enumpure.main, ["-q", *flags], input=nfg_matching_pennies_text + ) + assert result.exit_code == 0 + assert called == ["strategy"] + + def test_tree_game_agent_flag_selects_agent_solve( + self, cli_runner, monkeypatch, efg_small_tree_text + ): + called = [] + monkeypatch.setattr( + gbt.nash, "enumpure_solve", lambda *a, **k: called.append("strategy") or None + ) + monkeypatch.setattr( + gbt.nash, "enumpure_agent_solve", lambda *a, **k: called.append("agent") or None + ) + result = cli_runner.invoke(enumpure.main, ["-q", "-A"], input=efg_small_tree_text) + assert result.exit_code == 0 + assert called == ["agent"] + + def test_tree_game_without_agent_flag_selects_strategy_solve( + self, cli_runner, monkeypatch, efg_small_tree_text + ): + called = [] + monkeypatch.setattr( + gbt.nash, "enumpure_solve", lambda *a, **k: called.append("strategy") or None + ) + monkeypatch.setattr( + gbt.nash, "enumpure_agent_solve", lambda *a, **k: called.append("agent") or None + ) + result = cli_runner.invoke(enumpure.main, ["-q"], input=efg_small_tree_text) + assert result.exit_code == 0 + assert called == ["strategy"] + + def test_strategic_flag_converts_agent_result_for_display( + self, cli_runner, efg_asymmetric_tree_text + ): + """-A alone reports the behavior-form profile (7 values, per the fixture's + docstring); adding -S should convert each equilibrium found to its + strategic form (8 values) before printing it, not just relabel the same + 7 numbers. + """ + agent_result = cli_runner.invoke( + enumpure.main, ["-q", "-A"], input=efg_asymmetric_tree_text + ) + agent_strategic_result = cli_runner.invoke( + enumpure.main, ["-q", "-A", "-S"], input=efg_asymmetric_tree_text + ) + assert agent_result.exit_code == 0 + assert agent_strategic_result.exit_code == 0 + agent_lines = agent_result.stdout.strip().splitlines() + strategic_lines = agent_strategic_result.stdout.strip().splitlines() + assert agent_lines and strategic_lines + assert all(len(line.split(",")) == 8 for line in agent_lines) + assert all(len(line.split(",")) == 9 for line in strategic_lines) diff --git a/tests/cli/test_gnm.py b/tests/cli/test_gnm.py new file mode 100644 index 0000000000..2abed2632a --- /dev/null +++ b/tests/cli/test_gnm.py @@ -0,0 +1,115 @@ +"""Tests that gambit-gnm's switches produce the behavior they document.""" + +from pygambit.cli import gnm + + +def _start_file(tmp_path, text="0.9,0.1,0.9,0.1\n"): + path = tmp_path / "pert.csv" + path.write_text(text) + return path + + +def test_default_finds_equilibria_along_the_path(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + gnm.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip().splitlines() == [ + "NE,1.000000,0.000000,1.000000,0.000000", + "NE,0.750000,0.250000,0.250000,0.750000", + "NE,0.000000,1.000000,0.000000,1.000000", + ] + + +def test_decimals_flag_changes_precision(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + gnm.main, ["-q", "-d", "2", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip().splitlines()[0] == "NE,1.00,0.00,1.00,0.00" + + +def test_verbose_flag_adds_pert_and_start_and_step_lines( + cli_runner, nfg_asymmetric_table_text, tmp_path +): + start_file = _start_file(tmp_path) + plain = cli_runner.invoke( + gnm.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + verbose = cli_runner.invoke( + gnm.main, ["-q", "-V", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert verbose.exit_code == 0 + lines = verbose.stdout.strip().splitlines() + assert lines[0].startswith("pert,") + assert len(lines[0].split(",")) == 5 + assert lines[1].startswith("start,") + assert len(lines) > len(plain.stdout.strip().splitlines()) + for line in plain.stdout.strip().splitlines(): + assert line in verbose.stdout + + +def test_n_and_s_are_mutually_exclusive(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + gnm.main, ["-q", "-n", "2", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 1 + assert result.stderr == "Error: The -n and -s options are mutually exclusive.\n" + + +def test_seed_requires_n(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(gnm.main, ["-q", "-R", "5"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == "Error: The -R option requires -n.\n" + + +def test_n_controls_the_number_of_random_perturbations(cli_runner, nfg_asymmetric_table_text): + one = cli_runner.invoke( + gnm.main, ["-q", "-n", "1", "-R", "1"], input=nfg_asymmetric_table_text + ) + three = cli_runner.invoke( + gnm.main, ["-q", "-n", "3", "-R", "1"], input=nfg_asymmetric_table_text + ) + assert one.exit_code == 0 + assert three.exit_code == 0 + # each perturbation contributes at least one reported equilibrium + assert len(three.stdout.strip().splitlines()) >= 3 + assert len(three.stdout.strip().splitlines()) > len(one.stdout.strip().splitlines()) + + +def test_end_lambda_must_be_negative(cli_runner, nfg_asymmetric_table_text): + for bad_value in ("0", "5"): + result = cli_runner.invoke( + gnm.main, ["-q", "-m", bad_value], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 1 + assert result.stderr == "Error: Value for -m (end lambda) must be negative\n" + + +def test_local_newton_interval_must_be_positive(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(gnm.main, ["-q", "-f", "0"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == "Error: Value for -f (local Newton frequency) must be at least 1\n" + + +def test_local_newton_maxits_must_be_positive(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(gnm.main, ["-q", "-i", "0"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == ("Error: Value for -i (local Newton iterations) must be at least 1\n") + + +def test_steps_must_be_positive(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(gnm.main, ["-q", "-c", "0"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == "Error: Value for -c (steps in support cell) must be at least 1\n" + + +def test_validation_errors_happen_before_the_banner(cli_runner, nfg_asymmetric_table_text): + # The C++ tool prints the banner *then* validates -m/-f/-i/-c; the Python port + # must match that ordering (banner always appears, even on validation failure). + result = cli_runner.invoke(gnm.main, ["-m", "5"], input=nfg_asymmetric_table_text) + assert gnm.DESCRIPTION in result.stderr + assert "Error: Value for -m" in result.stderr diff --git a/tests/cli/test_ipa.py b/tests/cli/test_ipa.py new file mode 100644 index 0000000000..b0c58c5933 --- /dev/null +++ b/tests/cli/test_ipa.py @@ -0,0 +1,74 @@ +"""Tests that gambit-ipa's switches produce the behavior they document.""" + +from pygambit.cli import ipa + + +def _start_file(tmp_path, text="0.9,0.1,0.9,0.1\n"): + path = tmp_path / "pert.csv" + path.write_text(text) + return path + + +def test_default_finds_an_equilibrium(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + ipa.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.750000,0.250000,0.250000,0.750000" + + +def test_decimals_flag_changes_precision(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + ipa.main, ["-q", "-d", "2", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.75,0.25,0.25,0.75" + + +def test_verbose_flag_adds_iteration_labeled_lines( + cli_runner, nfg_asymmetric_table_text, tmp_path +): + start_file = _start_file(tmp_path) + plain = cli_runner.invoke( + ipa.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + verbose = cli_runner.invoke( + ipa.main, ["-q", "-V", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert verbose.exit_code == 0 + lines = verbose.stdout.strip().splitlines() + assert lines[-1] == plain.stdout.strip() + assert all(line.startswith("iter-") for line in lines[:-1]) + # iteration numbers appear in increasing order + iterations = [int(line.split(",")[0].removeprefix("iter-")) for line in lines[:-1]] + assert iterations == sorted(iterations) + + +def test_n_and_s_are_mutually_exclusive(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + ipa.main, ["-q", "-n", "2", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 1 + assert result.stderr == "Error: The -n and -s options are mutually exclusive.\n" + + +def test_seed_requires_n(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(ipa.main, ["-q", "-R", "5"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == "Error: The -R option requires -n.\n" + + +def test_n_controls_the_number_of_random_perturbations(cli_runner, nfg_asymmetric_table_text): + one = cli_runner.invoke( + ipa.main, ["-q", "-n", "1", "-R", "1"], input=nfg_asymmetric_table_text + ) + three = cli_runner.invoke( + ipa.main, ["-q", "-n", "3", "-R", "1"], input=nfg_asymmetric_table_text + ) + assert one.exit_code == 0 + assert three.exit_code == 0 + assert len(one.stdout.strip().splitlines()) == 1 + assert len(three.stdout.strip().splitlines()) == 3 diff --git a/tests/cli/test_lcp.py b/tests/cli/test_lcp.py new file mode 100644 index 0000000000..fa1a578cb8 --- /dev/null +++ b/tests/cli/test_lcp.py @@ -0,0 +1,71 @@ +"""Tests that gambit-lcp's switches produce the behavior they document.""" + +from pygambit.cli import lcp + + +def test_default_output_is_exact_rational(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(lcp.main, ["-q"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,1/2,1/2,1/2,1/2" + + +def test_decimals_flag_switches_to_floating_point(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(lcp.main, ["-q", "-d", "2"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.50,0.50,0.50,0.50" + + +def test_detail_flag_switches_to_multiline_report(cli_runner, nfg_coordination_text): + result = cli_runner.invoke(lcp.main, ["-q", "-D"], input=nfg_coordination_text) + assert result.exit_code == 0 + assert "NE," not in result.stdout + assert "Strategy profile for player 1:" in result.stdout + + +def test_strategic_flag_switches_tree_game_to_strategy_representation( + cli_runner, efg_asymmetric_tree_text +): + behavior_result = cli_runner.invoke(lcp.main, ["-q"], input=efg_asymmetric_tree_text) + strategic_result = cli_runner.invoke(lcp.main, ["-q", "-S"], input=efg_asymmetric_tree_text) + assert behavior_result.exit_code == 0 + assert strategic_result.exit_code == 0 + behavior_lines = behavior_result.stdout.strip().splitlines() + strategic_lines = strategic_result.stdout.strip().splitlines() + assert behavior_lines and strategic_lines + assert all(len(line.split(",")) == 8 for line in behavior_lines) + assert all(len(line.split(",")) == 9 for line in strategic_lines) + + +def test_stop_after_limits_number_of_equilibria(cli_runner, nfg_coordination_text): + unlimited = cli_runner.invoke(lcp.main, ["-q"], input=nfg_coordination_text) + limited = cli_runner.invoke(lcp.main, ["-q", "-e", "1"], input=nfg_coordination_text) + assert unlimited.exit_code == 0 + assert limited.exit_code == 0 + assert len(unlimited.stdout.strip().splitlines()) > 1 + assert len(limited.stdout.strip().splitlines()) == 1 + assert limited.stdout.strip() == unlimited.stdout.strip().splitlines()[0] + + +def test_stop_after_rejects_non_positive_integer(cli_runner, nfg_matching_pennies_text): + for bad_value in ("0", "-1", "abc", "1.5"): + result = cli_runner.invoke( + lcp.main, ["-q", "-e", bad_value], input=nfg_matching_pennies_text + ) + assert result.exit_code == 1 + assert result.stderr == ( + f"Error: -e argument must be a positive integer; got '{bad_value}'.\n" + ) + + +def test_stop_after_and_max_depth_are_silently_ignored_on_tree_game_without_strategic( + cli_runner, efg_asymmetric_tree_text +): + # lcp_solve() itself raises if stop_after/max_depth are given while solving on the + # tree representation; the CLI must not forward them in that case, matching the + # C++ tool, which simply never wires -e/-r into the tree-solving code path. + plain = cli_runner.invoke(lcp.main, ["-q"], input=efg_asymmetric_tree_text) + with_e_and_r = cli_runner.invoke( + lcp.main, ["-q", "-e", "1", "-r", "5"], input=efg_asymmetric_tree_text + ) + assert with_e_and_r.exit_code == 0 + assert with_e_and_r.stdout == plain.stdout diff --git a/tests/cli/test_liap.py b/tests/cli/test_liap.py new file mode 100644 index 0000000000..6bcc8d42dc --- /dev/null +++ b/tests/cli/test_liap.py @@ -0,0 +1,99 @@ +"""Tests that gambit-liap's switches produce the behavior they document.""" + +from pygambit.cli import liap + + +def test_default_finds_an_equilibrium_from_a_starting_file( + cli_runner, nfg_asymmetric_table_text, tmp_path +): + start_file = tmp_path / "start.csv" + start_file.write_text("0.5,0.5,0.5,0.5\n") + result = cli_runner.invoke( + liap.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.750000,0.250000,0.250000,0.750000" + + +def test_verbose_flag_adds_a_start_line(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = tmp_path / "start.csv" + start_file.write_text("0.5,0.5,0.5,0.5\n") + plain = cli_runner.invoke( + liap.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + verbose = cli_runner.invoke( + liap.main, ["-q", "-V", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert verbose.exit_code == 0 + assert verbose.stdout == "start,0.500000,0.500000,0.500000,0.500000\n" + plain.stdout + + +def test_decimals_flag_controls_precision(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = tmp_path / "start.csv" + start_file.write_text("0.5,0.5,0.5,0.5\n") + result = cli_runner.invoke( + liap.main, ["-q", "-d", "2", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.75,0.25,0.25,0.75" + + +def test_starting_points_file_can_hold_several_profiles( + cli_runner, nfg_asymmetric_table_text, tmp_path +): + start_file = tmp_path / "starts.csv" + start_file.write_text("0.5,0.5,0.5,0.5\n0.9,0.1,0.9,0.1\n") + result = cli_runner.invoke( + liap.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert len(result.stdout.strip().splitlines()) == 2 + + +def test_n_and_s_are_mutually_exclusive(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = tmp_path / "start.csv" + start_file.write_text("0.5,0.5,0.5,0.5\n") + result = cli_runner.invoke( + liap.main, ["-q", "-n", "2", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 1 + assert result.stderr == "Error: The -n and -s options are mutually exclusive.\n" + + +def test_seed_requires_n(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(liap.main, ["-q", "-R", "5"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == "Error: The -R option requires -n.\n" + + +def test_n_controls_the_number_of_random_starts(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke( + liap.main, ["-q", "-n", "4", "-R", "1"], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert len(result.stdout.strip().splitlines()) == 4 + + +def test_agent_flag_reports_behavior_form_on_a_tree_game( + cli_runner, efg_asymmetric_tree_text, tmp_path +): + start_file = tmp_path / "start.csv" + start_file.write_text("0.5,0.5,0.3,0.3,0.4,0.5,0.5\n") + result = cli_runner.invoke( + liap.main, ["-q", "-A", "-s", str(start_file)], input=efg_asymmetric_tree_text + ) + assert result.exit_code == 0 + lines = result.stdout.strip().splitlines() + assert lines + assert all(len(line.split(",")) == 8 for line in lines) + + +def test_agent_flag_has_no_effect_on_a_table_game(cli_runner, nfg_asymmetric_table_text): + without = cli_runner.invoke( + liap.main, ["-q", "-n", "3", "-R", "1"], input=nfg_asymmetric_table_text + ) + with_agent = cli_runner.invoke( + liap.main, ["-q", "-A", "-n", "3", "-R", "1"], input=nfg_asymmetric_table_text + ) + assert with_agent.exit_code == 0 + assert with_agent.stdout == without.stdout diff --git a/tests/cli/test_logit.py b/tests/cli/test_logit.py new file mode 100644 index 0000000000..d2831c0581 --- /dev/null +++ b/tests/cli/test_logit.py @@ -0,0 +1,119 @@ +"""Tests that gambit-logit's switches produce the behavior they document.""" + +import pathlib + +from pygambit.cli import logit + + +def test_default_streams_the_whole_branch_then_the_equilibrium( + cli_runner, nfg_asymmetric_table_text +): + result = cli_runner.invoke(logit.main, ["-q"], input=nfg_asymmetric_table_text) + assert result.exit_code == 0 + lines = result.stdout.strip().splitlines() + assert len(lines) > 2 + assert lines[0].startswith("0.000000,") + assert lines[-1].startswith("NE,") + + +def test_terminal_only_flag_suppresses_the_trace(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(logit.main, ["-q", "-e"], input=nfg_asymmetric_table_text) + assert result.exit_code == 0 + lines = result.stdout.strip().splitlines() + assert len(lines) == 1 + assert lines[0].startswith("NE,") + + +def test_decimals_flag_controls_lambda_label_precision(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(logit.main, ["-q", "-d", "2"], input=nfg_asymmetric_table_text) + assert result.exit_code == 0 + first_label = result.stdout.splitlines()[0].split(",")[0] + assert first_label == "0.00" + + +def test_profile_values_use_general_not_fixed_formatting(cli_runner, nfg_asymmetric_table_text): + # Values are trimmed of trailing zeros (0.51, not 0.510000), unlike the other + # tools' fixed-decimal CSV renderer. + result = cli_runner.invoke(logit.main, ["-q"], input=nfg_asymmetric_table_text) + assert result.exit_code == 0 + assert "0.510000" not in result.stdout + assert any( + "," in line and not line.split(",")[1].endswith("00000") + for line in result.stdout.splitlines()[1:5] + ) + + +def test_target_lambda_reports_only_the_requested_points(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke( + logit.main, ["-q", "-l", "1", "-l", "5"], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + lines = result.stdout.strip().splitlines() + assert len(lines) >= 2 + assert lines[-2].startswith("1.000000,") or lines[-1].startswith("1.000000,") + labels = [line.split(",")[0] for line in lines] + assert "1.000000" in labels + assert "5.000000" in labels + # -l never reports a plain "NE" summary line -- every row is lambda-labeled. + assert "NE" not in labels + + +def test_target_lambda_with_terminal_only_suppresses_the_trace_to_each_point( + cli_runner, nfg_asymmetric_table_text +): + full = cli_runner.invoke(logit.main, ["-q", "-l", "1"], input=nfg_asymmetric_table_text) + terminal_only = cli_runner.invoke( + logit.main, ["-q", "-e", "-l", "1"], input=nfg_asymmetric_table_text + ) + assert full.exit_code == 0 + assert terminal_only.exit_code == 0 + assert len(terminal_only.stdout.strip().splitlines()) == 1 + assert len(full.stdout.strip().splitlines()) > 1 + + +def test_strategic_flag_switches_tree_game_to_strategy_representation( + cli_runner, efg_asymmetric_tree_text +): + behavior_result = cli_runner.invoke(logit.main, ["-q", "-e"], input=efg_asymmetric_tree_text) + strategic_result = cli_runner.invoke( + logit.main, ["-q", "-e", "-S"], input=efg_asymmetric_tree_text + ) + assert behavior_result.exit_code == 0 + assert strategic_result.exit_code == 0 + assert len(behavior_result.stdout.strip().split(",")) == 8 + assert len(strategic_result.stdout.strip().split(",")) == 9 + + +def test_maximum_likelihood_estimate_appends_log_likelihood( + cli_runner, nfg_asymmetric_table_text, tmp_path +): + frequencies = tmp_path / "freq.csv" + frequencies.write_text("10,5,3,7\n") + result = cli_runner.invoke( + logit.main, ["-q", "-S", "-e", "-L", str(frequencies)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + fields = result.stdout.strip().split(",") + # lambda + 4 profile values + a trailing (non-positive) log-likelihood + assert len(fields) == 6 + assert float(fields[-1]) <= 0.0 + + +def test_maximum_likelihood_estimate_requires_readable_frequency_file( + cli_runner, nfg_asymmetric_table_text, tmp_path +): + missing = tmp_path / "does_not_exist.csv" + result = cli_runner.invoke( + logit.main, ["-q", "-S", "-L", str(missing)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 1 + assert result.stderr == f"Error: Error reading strategy frequencies from '{missing}'.\n" + + +def test_imperfect_recall_is_rejected(cli_runner): + imperfect_recall_efg = pathlib.Path("tests/test_games/gilboa_two_am_agents.efg").read_text() + result = cli_runner.invoke(logit.main, ["-q"], input=imperfect_recall_efg) + assert result.exit_code == 1 + assert result.stderr == ( + "Error: Computing equilibria of games with imperfect recall is not supported.\n" + ) diff --git a/tests/cli/test_lp.py b/tests/cli/test_lp.py new file mode 100644 index 0000000000..1d3767e101 --- /dev/null +++ b/tests/cli/test_lp.py @@ -0,0 +1,43 @@ +"""Tests that gambit-lp's switches produce the behavior they document.""" + +from pygambit.cli import lp + + +def test_default_output_is_exact_rational(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(lp.main, ["-q"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,1/2,1/2,1/2,1/2" + + +def test_decimals_flag_switches_to_floating_point(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(lp.main, ["-q", "-d", "2"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.50,0.50,0.50,0.50" + + +def test_detail_flag_switches_to_multiline_report(cli_runner, nfg_matching_pennies_text): + result = cli_runner.invoke(lp.main, ["-q", "-D"], input=nfg_matching_pennies_text) + assert result.exit_code == 0 + assert "NE," not in result.stdout + assert "Strategy profile for player 1:" in result.stdout + + +def test_strategic_flag_switches_tree_game_to_strategy_representation( + cli_runner, efg_small_tree_text +): + # efg_small_tree_text is zero-sum (lp requires constant-sum) with one infoset + # per player, so field counts alone won't distinguish -S; assert on the + # representation via -D's report header instead, which names the game's + # actual players/strategies only in strategy form. + behavior_result = cli_runner.invoke(lp.main, ["-q", "-D"], input=efg_small_tree_text) + strategic_result = cli_runner.invoke(lp.main, ["-q", "-D", "-S"], input=efg_small_tree_text) + assert behavior_result.exit_code == 0 + assert strategic_result.exit_code == 0 + assert "Behavior profile for player 1:" in behavior_result.stdout + assert "Strategy profile for player 1:" in strategic_result.stdout + + +def test_non_constant_sum_game_is_a_clean_error(cli_runner, nfg_coordination_text): + result = cli_runner.invoke(lp.main, ["-q"], input=nfg_coordination_text) + assert result.exit_code == 1 + assert result.stderr.startswith("Error:") diff --git a/tests/cli/test_simpdiv.py b/tests/cli/test_simpdiv.py new file mode 100644 index 0000000000..51e626237d --- /dev/null +++ b/tests/cli/test_simpdiv.py @@ -0,0 +1,71 @@ +"""Tests that gambit-simpdiv's switches produce the behavior they document.""" + +from pygambit.cli import simpdiv + + +def test_default_output_is_exact_rational(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(simpdiv.main, ["-q"], input=nfg_asymmetric_table_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,1,0,1,0" + + +def test_decimals_flag_switches_to_floating_point(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(simpdiv.main, ["-q", "-d", "3"], input=nfg_asymmetric_table_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,1.000,0.000,1.000,0.000" + + +def test_verbose_flag_adds_start_and_refinement_lines(cli_runner, nfg_asymmetric_table_text): + plain = cli_runner.invoke(simpdiv.main, ["-q"], input=nfg_asymmetric_table_text) + verbose = cli_runner.invoke(simpdiv.main, ["-q", "-V"], input=nfg_asymmetric_table_text) + assert verbose.exit_code == 0 + lines = verbose.stdout.strip().splitlines() + assert lines[0].startswith("start,") + assert lines[-1] == plain.stdout.strip() + assert len(lines) > 2 + + +def test_r_and_s_are_mutually_exclusive(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = tmp_path / "start.csv" + start_file.write_text("1/2,1/2,1/2,1/2\n") + result = cli_runner.invoke( + simpdiv.main, ["-q", "-r", "5", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 1 + assert result.stderr == "Error: The -r and -s options are mutually exclusive.\n" + + +def test_n_requires_r(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(simpdiv.main, ["-q", "-n", "3"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == "Error: The -n option requires -r.\n" + + +def test_seed_requires_n(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(simpdiv.main, ["-q", "-R", "5"], input=nfg_asymmetric_table_text) + assert result.exit_code == 1 + assert result.stderr == "Error: The -R option requires -n.\n" + + +def test_n_controls_the_number_of_random_starts(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke( + simpdiv.main, ["-q", "-r", "6", "-n", "3", "-R", "1"], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert len(result.stdout.strip().splitlines()) == 3 + + +def test_starting_points_file_is_used_exactly(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = tmp_path / "start.csv" + start_file.write_text("1/2,1/2,1/2,1/2\n") + result = cli_runner.invoke( + simpdiv.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,1,0,1,0" + + +def test_maxregret_accepts_a_rational_string(cli_runner, nfg_asymmetric_table_text): + result = cli_runner.invoke(simpdiv.main, ["-q", "-m", "1/10"], input=nfg_asymmetric_table_text) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,1,0,1,0" diff --git a/tests/cli/test_smoke.py b/tests/cli/test_smoke.py new file mode 100644 index 0000000000..dd99c2a1d6 --- /dev/null +++ b/tests/cli/test_smoke.py @@ -0,0 +1,74 @@ +"""Subprocess-based smoke tests for the installed `gambit-*` console-script entry +points. + +The rest of the CLI test suite drives each tool's `main()` in-process via +`click.testing.CliRunner`, which is fast but never executes the actual +`project.scripts` entry point, a real process exit code, or argv/stdin as the OS +would deliver them. These tests are the thin subprocess-based layer on top of +that: one process-level check per tool that the installed script runs, reads a +game from stdin, and produces the expected first line of output. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +_BIN_DIR = Path(sys.executable).parent + +# (tool name, fixture providing its input game, expected first output line) +TOOLS_AND_EXPECTED_FIRST_LINE = [ + ("gambit-enumpure", "nfg_coordination_text", "NE,1,0,1,0"), + ("gambit-enummixed", "nfg_matching_pennies_text", "NE,1/2,1/2,1/2,1/2"), + ("gambit-lcp", "nfg_matching_pennies_text", "NE,1/2,1/2,1/2,1/2"), + ("gambit-lp", "nfg_matching_pennies_text", "NE,1/2,1/2,1/2,1/2"), + ("gambit-enumpoly", "nfg_matching_pennies_text", "NE,0.500000,0.500000,0.500000,0.500000"), +] + + +def _script_path(name: str) -> Path: + path = _BIN_DIR / name + if not path.exists(): + pytest.skip(f"{name} is not installed alongside {sys.executable}") + return path + + +@pytest.mark.parametrize( + "tool_name,game_fixture,expected_first_line", + TOOLS_AND_EXPECTED_FIRST_LINE, + ids=[t[0] for t in TOOLS_AND_EXPECTED_FIRST_LINE], +) +def test_installed_script_reads_stdin_and_runs( + tool_name, game_fixture, expected_first_line, request +): + script = _script_path(tool_name) + game_text = request.getfixturevalue(game_fixture) + proc = subprocess.run( + [str(script), "-q"], + input=game_text, + capture_output=True, + text=True, + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip().splitlines()[0] == expected_first_line + + +def test_installed_script_exits_nonzero_on_bad_option(): + script = _script_path("gambit-enumpure") + proc = subprocess.run( + [str(script), "--this-flag-does-not-exist"], + capture_output=True, + text=True, + timeout=30, + ) + assert proc.returncode != 0 + + +def test_installed_script_help_exits_zero(): + script = _script_path("gambit-enumpure") + proc = subprocess.run([str(script), "-h"], capture_output=True, text=True, timeout=30) + assert proc.returncode == 0 + assert "enumerating extreme points" not in proc.stdout # sanity: this is enumpure's help + assert "pure strategies" in proc.stdout diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py new file mode 100644 index 0000000000..3abb705b11 --- /dev/null +++ b/tests/test_behavspt_profiles.py @@ -0,0 +1,189 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def _branching_game(): + """A small tree where P1 chooses L/R, each leading to a separate P2 decision, so + that removing an action can make a whole subtree's information set unreachable. + """ + game = gbt.Game.new_tree(players=["P1", "P2"]) + root = game.root + game.append_move(root, "P1", ["L", "R"]) + left = root.children["L"] + right = root.children["R"] + game.append_move(left, "P2", ["A", "B"]) + game.append_move(right, "P2", ["A", "B"]) + root.infoset.label = "P1 infoset" + left.infoset.label = "P2 left infoset" + right.infoset.label = "P2 right infoset" + return game, root.infoset, left.infoset, right.infoset + + +def test_getitem_by_infoset(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + infoset = game.infosets["Infoset 1:1"] + support = profile[infoset] + assert set(support) == {"U1", "D1"} + assert support.infoset == infoset + assert "U1" in support + assert "not-a-label" not in support + + +def test_getitem_by_player_label(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + support = profile["Player 1"] + assert support.player == game.players["Player 1"] + infoset = game.infosets["Infoset 1:1"] + assert set(support[infoset]) == {"U1", "D1"} + + +def test_getitem_unknown_player(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + with pytest.raises(KeyError): + profile["not-a-player"] + + +def test_getitem_rejects_other_types(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + with pytest.raises(TypeError): + profile[0] + + +def test_getitem_infoset_wrong_game(): + game = games.read_from_file("mixed_behavior_game.efg") + other = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + with pytest.raises(gbt.MismatchError): + profile[other.infosets["Infoset 1:1"]] + + +def test_predicate_construction(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile(lambda a: a.label != "D1") + infoset = game.infosets["Infoset 1:1"] + assert set(profile[infoset]) == {"U1"} + + +def test_predicate_construction_error(): + game = games.read_from_file("mixed_behavior_game.efg") + with pytest.raises(ValueError): + game.behavior_support_profile(lambda a: a.infoset.label != "Infoset 1:1") + + +def test_iter_yields_one_support_per_player(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + supports = list(profile) + assert len(supports) == len(game.players) + assert {s.player.label for s in supports} == {p.label for p in game.players} + + +def test_behaviorsupport_iter(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + support = profile["Player 1"] + action_supports = list(support) + assert len(action_supports) == len(game.players["Player 1"].infosets) + assert all(isinstance(s, gbt.ActionSupport) for s in action_supports) + + +def test_setitem_replaces_support(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + infoset = game.infosets["Infoset 1:1"] + profile[infoset] = ["U1"] + assert set(profile[infoset]) == {"U1"} + + +def test_setitem_unknown_label(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + infoset = game.infosets["Infoset 1:1"] + with pytest.raises(ValueError): + profile[infoset] = ["not-a-label"] + + +def test_setitem_empty(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + infoset = game.infosets["Infoset 1:1"] + with pytest.raises(ValueError): + profile[infoset] = [] + + +def test_setitem_rejects_non_infoset(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + with pytest.raises(TypeError): + profile["Infoset 1:1"] = ["U1"] + + +def test_setitem_infoset_wrong_game(): + game = games.read_from_file("mixed_behavior_game.efg") + other = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + with pytest.raises(gbt.MismatchError): + profile[other.infosets["Infoset 1:1"]] = ["U1"] + + +def test_copy_is_independent(): + game = games.read_from_file("mixed_behavior_game.efg") + original = game.behavior_support_profile() + copy = original.copy() + infoset = game.infosets["Infoset 1:1"] + copy[infoset] = ["U1"] + assert set(copy[infoset]) == {"U1"} + assert set(original[infoset]) == {"U1", "D1"} + + +def test_actionsupport_is_snapshot(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.behavior_support_profile() + infoset = game.infosets["Infoset 1:1"] + snapshot = profile[infoset] + profile[infoset] = ["U1"] + assert set(snapshot) == {"U1", "D1"} + + +def test_getitem_setitem_accept_node_infoset(): + game, root_infoset, left_infoset, right_infoset = _branching_game() + profile = game.behavior_support_profile() + # game.root.infoset is a NodeInfoset, not a bare Infoset -- both __getitem__ and + # __setitem__ must resolve it the same way Node.infoset is used everywhere else. + assert set(profile[game.root.infoset]) == {"L", "R"} + profile[game.root.infoset] = ["R"] + assert set(profile[game.root.infoset]) == {"R"} + + +def test_is_reachable(): + game, root_infoset, left_infoset, right_infoset = _branching_game() + profile = game.behavior_support_profile() + assert profile.is_reachable(left_infoset) + assert profile.is_reachable(right_infoset) + + copy = profile.copy() + copy[root_infoset] = ["R"] + assert not copy.is_reachable(left_infoset) + assert copy.is_reachable(right_infoset) + # the original, un-mutated profile is unaffected + assert profile.is_reachable(left_infoset) + + +def test_is_reachable_by_label(): + game, root_infoset, left_infoset, right_infoset = _branching_game() + profile = game.behavior_support_profile() + assert profile.is_reachable(left_infoset.label) + + +def test_is_reachable_wrong_game(): + _, _, left_infoset, _ = _branching_game() + other, *_ = _branching_game() + with pytest.raises(gbt.MismatchError): + other.behavior_support_profile().is_reachable(left_infoset) From ea01ce9f6f81a07ad5a4f91f7b1d915153a782db Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 26 Aug 2026 15:42:31 +0100 Subject: [PATCH 05/11] Remove C++ drivers --- Makefile.am | 74 +------- build_support/msw/gambit.wxs.in | 36 ---- doc/install.rst | 6 - doc/tools.rst | 7 +- src/tools/convert/convert.cc | 150 ---------------- src/tools/enummixed/enummixed.cc | 153 ---------------- src/tools/enumpoly/enumpoly.cc | 256 --------------------------- src/tools/enumpure/enumpure.cc | 142 --------------- src/tools/gt/nfggnm.cc | 242 ------------------------- src/tools/gt/nfggt.cc | 51 ------ src/tools/gt/nfgipa.cc | 196 --------------------- src/tools/lcp/lcp.cc | 188 -------------------- src/tools/liap/liap.cc | 288 ------------------------------ src/tools/logit/logit.cc | 270 ---------------------------- src/tools/lp/lp.cc | 158 ----------------- src/tools/simpdiv/nfgsimpdiv.cc | 277 ----------------------------- src/tools/util.h | 293 ------------------------------- 17 files changed, 2 insertions(+), 2785 deletions(-) delete mode 100644 src/tools/convert/convert.cc delete mode 100644 src/tools/enummixed/enummixed.cc delete mode 100644 src/tools/enumpoly/enumpoly.cc delete mode 100644 src/tools/enumpure/enumpure.cc delete mode 100644 src/tools/gt/nfggnm.cc delete mode 100644 src/tools/gt/nfggt.cc delete mode 100644 src/tools/gt/nfgipa.cc delete mode 100644 src/tools/lcp/lcp.cc delete mode 100644 src/tools/liap/liap.cc delete mode 100644 src/tools/logit/logit.cc delete mode 100644 src/tools/lp/lp.cc delete mode 100644 src/tools/simpdiv/nfgsimpdiv.cc delete mode 100644 src/tools/util.h diff --git a/Makefile.am b/Makefile.am index bff1ba7cc0..d74081a157 100644 --- a/Makefile.am +++ b/Makefile.am @@ -342,21 +342,8 @@ if IS_WIN32 AM_LDFLAGS = -static -static-libgcc -static-libstdc++ endif -bin_PROGRAMS = \ - gambit-convert \ - gambit-enummixed \ - gambit-enumpoly \ - gambit-enumpure \ - gambit-gnm \ - gambit-ipa \ - gambit-lcp \ - gambit-liap \ - gambit-logit \ - gambit-lp \ - gambit-simpdiv - if WITH_GUI -bin_PROGRAMS += gambit +bin_PROGRAMS = gambit endif # Read version from GAMBIT_VERSION file for use in compilation @@ -384,64 +371,6 @@ liblogit_a_SOURCES = ${logit_SOURCES} libsimpdiv_a_SOURCES = ${simpdiv_SOURCES} libenumpoly_a_SOURCES = ${enumpoly_SOURCES} -## Command-line tools - -gambit_convert_SOURCES = \ - src/tools/convert/convert.cc -gambit_convert_LDADD = libgames.a libcore.a - -gambit_enummixed_SOURCES = \ - src/tools/util.h \ - src/tools/enummixed/enummixed.cc -gambit_enummixed_LDADD = libbimatrix.a libgames.a libcore.a - -gambit_enumpoly_SOURCES = \ - src/tools/util.h \ - src/tools/enumpoly/enumpoly.cc -gambit_enumpoly_LDADD = libenumpoly.a libgames.a libcore.a - -gambit_enumpure_SOURCES = \ - src/solvers/enumpure/enumpure.h \ - src/tools/util.h \ - src/tools/enumpure/enumpure.cc -gambit_enumpure_LDADD = libgames.a libcore.a - -gambit_gnm_SOURCES = \ - src/tools/gt/nfggt.cc \ - src/tools/gt/nfggnm.cc -gambit_gnm_LDADD = libgtracer.a libgames.a libcore.a - -gambit_ipa_SOURCES = \ - src/tools/gt/nfggt.cc \ - src/tools/util.h \ - src/tools/gt/nfgipa.cc -gambit_ipa_LDADD = libgtracer.a libgames.a libcore.a - -gambit_lcp_SOURCES = \ - src/tools/util.h \ - src/tools/lcp/lcp.cc -gambit_lcp_LDADD = libbimatrix.a libgames.a libcore.a - -gambit_liap_SOURCES = \ - src/tools/util.h \ - src/tools/liap/liap.cc -gambit_liap_LDADD = libliap.a libgames.a libcore.a - -gambit_logit_SOURCES = \ - src/tools/util.h \ - src/tools/logit/logit.cc -gambit_logit_LDADD = liblogit.a libgames.a libcore.a - -gambit_lp_SOURCES = \ - src/tools/util.h \ - src/tools/lp/lp.cc -gambit_lp_LDADD = libbimatrix.a libgames.a libcore.a - -gambit_simpdiv_SOURCES = \ - src/tools/util.h \ - src/tools/simpdiv/nfgsimpdiv.cc -gambit_simpdiv_LDADD = libsimpdiv.a libgames.a libcore.a - gambit_SOURCES = \ src/gui/analysis.cc \ src/gui/analysis.h \ @@ -554,4 +483,3 @@ clang-tidy: clang-tidy ${top_srcdir}/src/core/*.cc -- --std=c++20 -I ${top_srcdir}/src -DVERSION=\"$(GAMBIT_VERSION)\" clang-tidy ${top_srcdir}/src/games/*.cc ${top_srcdir}/src/games/*/*.cc -- --std=c++20 -I ${top_srcdir}/src -DVERSION=\"$(GAMBIT_VERSION)\" clang-tidy ${top_srcdir}/src/solvers/*/*.cc -- --std=c++20 -I ${top_srcdir}/src -DVERSION=\"$(GAMBIT_VERSION)\" - clang-tidy ${top_srcdir}/src/tools/*/*.cc -- --std=c++20 -I ${top_srcdir}/src -DVERSION=\"$(GAMBIT_VERSION)\" diff --git a/build_support/msw/gambit.wxs.in b/build_support/msw/gambit.wxs.in index 1c2d7c8a24..cd3313d610 100644 --- a/build_support/msw/gambit.wxs.in +++ b/build_support/msw/gambit.wxs.in @@ -24,33 +24,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -71,15 +44,6 @@ - - - - - - - - - diff --git a/doc/install.rst b/doc/install.rst index c0a1eac9c0..44db4e342e 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -100,12 +100,6 @@ The current stable release of Gambit is |release|. `./configure --prefix=/your/path/here` - .. warning:: - The graphical interface relies on external calls to other - programs built in this process, especially for the computation of - equilibria. It is strongly recommended that you install the Gambit - executables to a directory in your path! - All past releases are available on the `GitHub releases page `__. diff --git a/doc/tools.rst b/doc/tools.rst index 7f5aba15a2..ed0ce7a5d9 100644 --- a/doc/tools.rst +++ b/doc/tools.rst @@ -4,7 +4,7 @@ CLI *** -See installation instructions in the :ref:`install-cli-gui` section. +These programs are installed as part of :ref:`PyGambit `. Gambit provides command-line interfaces for each method for computing Nash equilibria. These are suitable for scripting or calling from @@ -12,10 +12,6 @@ other programs. This chapter describes the use of these programs. For a general overview of methods for computing equilibria, see the survey of :cite:p:`McKMcL96`. -The graphical interface also provides a frontend for calling these -programs and evaluating their output. Direct use of the command-line -programs is intended for advanced users and applications. - These programs take an extensive or strategic game file, which can be specified on the command line or piped via standard input, and output a list of equilibria computed. The default output format is to present @@ -49,4 +45,3 @@ documentation. tools.logit tools.gnm tools.ipa - tools.convert diff --git a/src/tools/convert/convert.cc b/src/tools/convert/convert.cc deleted file mode 100644 index 4b6c44fb4c..0000000000 --- a/src/tools/convert/convert.cc +++ /dev/null @@ -1,150 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/convert/convert.cc -// Convert among various file formats -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include - -#include "games.h" -#include "games/writer.h" - -using namespace Gambit; - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Convert games among various file formats\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS]\n"; - std::cerr << "Accepts input file on standard input.\n"; - std::cerr << "Converts games among various file formats\n"; - - std::cerr << "Options:\n"; - std::cerr << " -O FORMAT output file format (required):\n"; - std::cerr << " FORMAT=html convert to HTML\n"; - std::cerr << " FORMAT=sgame convert to LaTeX sgame style\n"; - std::cerr << " -c PLAYER the player to show on columns (default is 2)\n"; - std::cerr << " -r PLAYER the player to show on rows (default is 1)\n"; - std::cerr << " -h print this help message\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - int c; - int rowPlayer = 1, colPlayer = 2; - bool quiet = false; - std::string format; - - while ((c = getopt(argc, argv, "r:c:hqO:")) != -1) { - switch (c) { - case 'r': - rowPlayer = atoi(optarg); - break; - case 'c': - colPlayer = atoi(optarg); - break; - case 'O': - format = optarg; - break; - case 'q': - quiet = true; - break; - case 'h': - PrintHelp(argv[0]); - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - if (format.empty()) { - std::cerr << argv[0] << ": Output format argument -O required.\n"; - return 1; - } - else if (format != "sgame" && format != "html") { - std::cerr << argv[0] << ": Unknown output format '" << format << "'.\n"; - return 1; - } - - if (rowPlayer == colPlayer) { - std::cerr << argv[0] << ": Row and column players must be different.\n"; - return 1; - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - - if (rowPlayer < 1 || rowPlayer > static_cast(game->NumPlayers())) { - std::cerr << argv[0] << ": Player " << rowPlayer << " does not exist.\n"; - return 1; - } - if (colPlayer < 1 || colPlayer > static_cast(game->NumPlayers())) { - std::cerr << argv[0] << ": Player " << colPlayer << " does not exist.\n"; - return 1; - } - - if (format == "html") { - std::cout << WriteHTMLFile(game, game->GetPlayer(rowPlayer), game->GetPlayer(colPlayer)); - } - else { - std::cout << WriteLaTeXFile(game, game->GetPlayer(rowPlayer), game->GetPlayer(colPlayer)); - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/enummixed/enummixed.cc b/src/tools/enummixed/enummixed.cc deleted file mode 100644 index d16369a785..0000000000 --- a/src/tools/enummixed/enummixed.cc +++ /dev/null @@ -1,153 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/enummixed/enummixed.cc -// Compute Nash equilibria via Mangasarian's algorithm -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include - -#include "games.h" -#include "tools/util.h" -#include "solvers/enummixed/enummixed.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -template -void PrintCliques(const Array>> &p_cliques, - std::shared_ptr> p_renderer) -{ - for (auto [cl, clique] : enumerate(p_cliques)) { - const std::string label = "convex-" + std::to_string(cl + 1); - for (const auto &profile : clique) { - p_renderer->Render(profile, label); - } - } -} - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria by enumerating extreme points\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - std::cerr << "With no options, reports all Nash equilibria found.\n\n"; - - std::cerr << "Options:\n"; - std::cerr << " -d DECIMALS compute using floating-point arithmetic;\n"; - std::cerr << " display results with DECIMALS digits\n"; - std::cerr << " -c output connectedness information\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - int c; - bool useFloat = false, quiet = false; - bool showConnect = false; - int numDecimals = 6; - - int long_opt_index = 0; - option long_options[] = { - {"help", 0, nullptr, 'h'}, {"version", 0, nullptr, 'v'}, {nullptr, 0, nullptr, 0}}; - while ((c = getopt_long(argc, argv, "d:vhqc", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'd': - useFloat = true; - numDecimals = atoi(optarg); - break; - case 'h': - PrintHelp(argv[0]); - break; - case 'c': - showConnect = true; - break; - case 'q': - quiet = true; - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - if (useFloat) { - auto renderer = MakeMixedStrategyProfileRenderer(std::cout, numDecimals, false); - auto solution = EnumMixedStrategySolveDetailed( - game, [&](const MixedStrategyProfile &p) { renderer->Render(p); }); - if (showConnect) { - PrintCliques(solution->GetCliques(), renderer); - } - } - else { - auto renderer = MakeMixedStrategyProfileRenderer(std::cout, numDecimals, false); - auto solution = EnumMixedStrategySolveDetailed( - game, [&](const MixedStrategyProfile &p) { renderer->Render(p); }); - if (showConnect) { - PrintCliques(solution->GetCliques(), renderer); - } - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/enumpoly/enumpoly.cc b/src/tools/enumpoly/enumpoly.cc deleted file mode 100644 index 0539629ff7..0000000000 --- a/src/tools/enumpoly/enumpoly.cc +++ /dev/null @@ -1,256 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/enumpoly/enumpoly.cc -// Enumerates all Nash equilibria via support enumeration -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include -#include -#include -#include -#include -#include "games.h" -#include "solvers/enumpoly/enumpoly.h" - -using namespace Gambit; -using namespace Nash; - -int g_numDecimals = 6; -bool g_verbose = false; - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria by solving polynomial systems\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - std::cerr << "With no options, reports all Nash equilibria found.\n\n"; - - std::cerr << "Options:\n"; - std::cerr << " -d DECIMALS show equilibrium probabilities with DECIMALS digits\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -S use strategic game\n"; - std::cerr << " -m MAXREGRET maximum regret acceptable as a proportion of range of\n"; - std::cerr << " payoffs in the game (default is 1e-8)\n"; - std::cerr << " -e EQA terminate after finding EQA equilibria\n"; - std::cerr << " (default is to search in all supports)\n"; - std::cerr << " -r RECTANGLES maximum number of rectangles to examine when searching\n"; - std::cerr << " for roots on a single support, before giving up on that\n"; - std::cerr << " support (default " << Nash::kDefaultEnumPolyMaxRectangles - << ")\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -V, --verbose verbose mode (shows supports investigated)\n"; - std::cerr << " (default is only to show equilibria)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -void PrintProfile(std::ostream &p_stream, const std::string &p_label, - const MixedStrategyProfile &p_profile) -{ - p_stream << p_label; - for (size_t i = 1; i <= p_profile.MixedProfileLength(); i++) { - p_stream.setf(std::ios::fixed); - p_stream << ',' << std::setprecision(g_numDecimals) << p_profile[i]; - } - p_stream << std::endl; -} - -void PrintSupport(std::ostream &p_stream, const std::string &p_label, - const StrategySupportProfile &p_support) -{ - if (!g_verbose) { - return; - } - p_stream << p_label; - for (auto player : p_support.GetGame()->GetPlayers()) { - p_stream << ","; - for (auto strategy : player->GetStrategies()) { - p_stream << ((p_support.Contains(strategy)) ? 1 : 0); - } - } - p_stream << std::endl; -} - -void PrintProfile(std::ostream &p_stream, const std::string &p_label, - const MixedBehaviorProfile &p_profile) -{ - p_stream << p_label; - for (size_t i = 1; i <= p_profile.BehaviorProfileLength(); i++) { - p_stream.setf(std::ios::fixed); - p_stream << "," << std::setprecision(g_numDecimals) << p_profile[i]; - } - p_stream << std::endl; -} - -void PrintSupport(std::ostream &p_stream, const std::string &p_label, - const BehaviorSupportProfile &p_support) -{ - if (!g_verbose) { - return; - } - p_stream << p_label; - for (auto player : p_support.GetGame()->GetPlayers()) { - for (auto infoset : player->GetInfosets()) { - p_stream << ","; - for (auto action : infoset->GetActions()) { - p_stream << ((p_support.Contains(action)) ? 1 : 0); - } - } - } - p_stream << std::endl; -} - -template void PrintEnumPolyEvent(const EnumPolyEvent &p_event) -{ - std::visit( - [](const Event &event) { - if constexpr (std::is_same_v>) { - PrintSupport(std::cout, "candidate", event.support); - } - else if constexpr (std::is_same_v>) { - PrintSupport(std::cout, "singular", event.support); - } - else if constexpr (std::is_same_v>) { - PrintSupport(std::cout, "budget-exceeded", event.support); - } - }, - p_event); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - - bool quiet = false; - bool useStrategic = false; - double maxregret = 1.0e-8; - std::optional stopAfter; - size_t maxRectangles = Nash::kDefaultEnumPolyMaxRectangles; - - int long_opt_index = 0; - option long_options[] = {{"help", 0, nullptr, 'h'}, - {"version", 0, nullptr, 'v'}, - {"verbose", 0, nullptr, 'V'}, - {nullptr, 0, nullptr, 0}}; - int c; - while ((c = getopt_long(argc, argv, "d:hSm:e:r:qvV", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'd': - g_numDecimals = atoi(optarg); - break; - case 'h': - PrintHelp(argv[0]); - break; - case 'S': - useStrategic = true; - break; - case 'm': - maxregret = atof(optarg); - break; - case 'e': { - size_t parsed; - const auto *begin = optarg; - const auto *end = optarg + std::strlen(optarg); - const auto result = std::from_chars(begin, end, parsed); - if (result.ec != std::errc{} || result.ptr != end || parsed == 0) { - std::cerr << "Error: -e argument must be a positive integer; got '" << optarg << "'." - << std::endl; - exit(1); - } - stopAfter = parsed; - break; - } - case 'r': - maxRectangles = std::strtoull(optarg, nullptr, 10); - break; - case 'q': - quiet = true; - break; - case 'V': - g_verbose = true; - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - if (!game->IsPerfectRecall()) { - throw UndefinedException( - "Computing equilibria of games with imperfect recall is not supported."); - } - - if (!game->IsTree() || useStrategic) { - EnumPolyStrategySolve( - game, stopAfter, maxregret, maxRectangles, - [](const MixedStrategyProfile &eqm) { PrintProfile(std::cout, "NE", eqm); }, - [](const EnumPolyEvent &event) { PrintEnumPolyEvent(event); }); - } - else { - EnumPolyBehaviorSolve( - game, stopAfter, maxregret, maxRectangles, - [](const MixedBehaviorProfile &eqm) { PrintProfile(std::cout, "NE", eqm); }, - [](const EnumPolyEvent &event) { PrintEnumPolyEvent(event); }); - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/enumpure/enumpure.cc b/src/tools/enumpure/enumpure.cc deleted file mode 100644 index 72c3ed84f3..0000000000 --- a/src/tools/enumpure/enumpure.cc +++ /dev/null @@ -1,142 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/enumpure/enumpure.cc -// Compute pure-strategy equilibria in extensive form games -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include - -#include "games.h" -#include "tools/util.h" -#include "solvers/enumpure/enumpure.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Search for Nash equilibria in pure strategies\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - std::cerr << "With no options, locates all Nash equilibria in pure strategies.\n\n"; - - std::cerr << "Options:\n"; - std::cerr << " -S report equilibria in strategies even for extensive games\n"; - std::cerr << " -A compute agent form equilibria\n"; - std::cerr << " -D print detailed information about equilibria\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - bool quiet = false, reportStrategic = false, solveAgent = false; - bool printDetail = false; - - int long_opt_index = 0; - option long_options[] = { - {"help", 0, nullptr, 'h'}, {"version", 0, nullptr, 'v'}, {nullptr, 0, nullptr, 0}}; - int c; - while ((c = getopt_long(argc, argv, "DvhqAS", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'D': - printDetail = true; - break; - case 'S': - reportStrategic = true; - break; - case 'A': - solveAgent = true; - break; - case 'h': - PrintHelp(argv[0]); - break; - case 'q': - quiet = true; - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - std::shared_ptr> renderer; - if (reportStrategic || !game->IsTree()) { - renderer = MakeMixedStrategyProfileRenderer(std::cout, 0, printDetail); - } - else { - renderer = MakeMixedBehaviorProfileRenderer(std::cout, 0, printDetail); - } - - if (game->IsTree() && solveAgent) { - EnumPureAgentSolve(game, - [&](const MixedBehaviorProfile &p) { renderer->Render(p); }); - } - else { - EnumPureStrategySolve(game, - [&](const MixedStrategyProfile &p) { renderer->Render(p); }); - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/gt/nfggnm.cc b/src/tools/gt/nfggnm.cc deleted file mode 100644 index d6f72280c7..0000000000 --- a/src/tools/gt/nfggnm.cc +++ /dev/null @@ -1,242 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/gt/nfggnm.cc -// Gambit frontend to Gametracer global Newton method -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include -#include -#include "games.h" -#include "tools/util.h" -#include "solvers/gnm/gnm.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -namespace { - -void RenderGNMEvent(const GNMEvent &p_event, - const std::shared_ptr> &p_renderer) -{ - std::visit( - [p_renderer](const Event &event) { - if constexpr (std::is_same_v) { - p_renderer->Render(event.profile, "pert"); - } - else if constexpr (std::is_same_v) { - p_renderer->Render(event.profile, "start"); - } - else if constexpr (std::is_same_v) { - p_renderer->Render(event.profile, lexical_cast(event.lambda)); - } - }, - p_event); -} - -} // namespace - -extern std::vector> ReadStrategyPerturbations(const Game &p_game, - std::istream &p_stream); - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria using a global Newton method\n"; - p_stream << "Gametracer version 0.2, Copyright (C) 2002, Ben Blum and Christian Shelton\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - - std::cerr << "Options:\n"; - std::cerr << " -d DECIMALS show equilibria as floating point with DECIMALS digits\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -n COUNT number of perturbation vectors to generate randomly\n"; - std::cerr << " (mutually exclusive with -s)\n"; - std::cerr << " -R SEED seed the random number generator used to generate\n"; - std::cerr << " perturbation vectors (default is to seed from system\n"; - std::cerr << " entropy); requires -n\n"; - std::cerr << " -s FILE file containing perturbation vectors\n"; - std::cerr << " (mutually exclusive with -n)\n"; - std::cerr << " -m LAMBDA lambda to end tracing (must be negative, default " - << std::to_string(GNM_LAMBDA_END_DEFAULT) << ")\n"; - std::cerr << " -f FREQ frequency to run local Newton method (default " - << std::to_string(GNM_LOCAL_NEWTON_INTERVAL_DEFAULT) << ")\n"; - std::cerr << " -i MAXITS maximum iterations in local Newton (default " - << std::to_string(GNM_LOCAL_NEWTON_MAXITS_DEFAULT) << ")\n"; - std::cerr << " -c STEPS number of steps in each support cell (default " - << std::to_string(GNM_STEPS_DEFAULT) << ")\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -V, --verbose verbose mode (shows intermediate output)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - bool quiet = false, verbose = false, numVectorsSet = false; - int numDecimals = 6, numVectors = 1; - double lambdaEnd = GNM_LAMBDA_END_DEFAULT; - int localNewtonInterval = GNM_LOCAL_NEWTON_INTERVAL_DEFAULT; - int localNewtonMaxits = GNM_LOCAL_NEWTON_MAXITS_DEFAULT; - int steps = GNM_STEPS_DEFAULT; - std::string startFile; - std::optional seed; - - int long_opt_index = 0; - option long_options[] = {{"help", 0, nullptr, 'h'}, - {"version", 0, nullptr, 'v'}, - {"verbose", 0, nullptr, 'V'}, - {nullptr, 0, nullptr, 0}}; - int c; - while ((c = getopt_long(argc, argv, "d:n:s:m:f:i:c:R:qvVh", long_options, &long_opt_index)) != - -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'q': - quiet = true; - break; - case 'V': - verbose = true; - break; - case 'd': - numDecimals = atoi(optarg); - break; - case 'n': - numVectors = atoi(optarg); - numVectorsSet = true; - break; - case 'R': - seed = std::strtoul(optarg, nullptr, 10); - break; - case 's': - startFile = optarg; - break; - case 'm': - lambdaEnd = atof(optarg); - break; - case 'f': - localNewtonInterval = atoi(optarg); - break; - case 'i': - localNewtonMaxits = atoi(optarg); - break; - case 'c': - steps = atoi(optarg); - break; - case 'h': - PrintHelp(argv[0]); - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - if (lambdaEnd >= 0.0) { - std::cerr << "Error: Value for -m (end lambda) must be negative\n"; - return 1; - } - if (localNewtonInterval <= 0) { - std::cerr << "Error: Value for -f (local Newton frequency) must be at least 1\n"; - return 1; - } - if (localNewtonMaxits <= 0) { - std::cerr << "Error: Value for -i (local Newton iterations) must be at least 1\n"; - return 1; - } - if (steps <= 0) { - std::cerr << "Error: Value for -c (steps in support cell) must be at least 1\n"; - return 1; - } - if (numVectorsSet && !startFile.empty()) { - std::cerr << "Error: The -n and -s options are mutually exclusive.\n"; - return 1; - } - if (seed && !numVectorsSet) { - std::cerr << "Error: The -R option requires -n.\n"; - return 1; - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - return 1; - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - auto renderer = MakeMixedStrategyProfileRenderer(std::cout, numDecimals, false); - std::vector> perts; - if (!startFile.empty()) { - std::ifstream startPerts(startFile.c_str()); - perts = ReadStrategyPerturbations(game, startPerts); - } - else { - // Generate the desired number of points randomly - auto engine = MakeRandomEngine(seed); - perts = NewRandomStrategyProfiles(game, numVectors, engine); - } - for (auto pert : perts) { - GNMStrategySolve( - pert, lambdaEnd, steps, localNewtonInterval, localNewtonMaxits, - [renderer](const MixedStrategyProfile &p_profile) { - renderer->Render(p_profile); - }, - [renderer, verbose](const GNMEvent &p_event) { - if (verbose) { - RenderGNMEvent(p_event, renderer); - } - }); - } - return 0; - } - catch (std::exception &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/gt/nfggt.cc b/src/tools/gt/nfggt.cc deleted file mode 100644 index 6a423b28e0..0000000000 --- a/src/tools/gt/nfggt.cc +++ /dev/null @@ -1,51 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/gt/nfggt.cc -// Gambit frontend to Gametracer -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include "games.h" - -using namespace Gambit; - -std::vector> ReadStrategyPerturbations(const Game &p_game, - std::istream &p_stream) -{ - std::vector> profiles; - while (!p_stream.eof() && !p_stream.bad()) { - MixedStrategyProfile p(p_game->NewMixedStrategyProfile(0.0)); - if (p_stream.peek() == EOF) { - break; - } - p_stream >> p[1]; - for (size_t i = 2; i <= p.MixedProfileLength(); i++) { - if (p_stream.eof() || p_stream.bad()) { - break; - } - char comma; - p_stream >> comma; - p_stream >> p[i]; - } - // Read in the rest of the line and discard - std::string foo; - std::getline(p_stream, foo); - profiles.push_back(p); - } - return profiles; -} diff --git a/src/tools/gt/nfgipa.cc b/src/tools/gt/nfgipa.cc deleted file mode 100644 index ab52fd6534..0000000000 --- a/src/tools/gt/nfgipa.cc +++ /dev/null @@ -1,196 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/gt/nfgipa.cc -// Gambit frontend to Gametracer IPA -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include -#include "games.h" -#include "tools/util.h" -#include "solvers/ipa/ipa.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -namespace { - -void RenderIPAEvent(const IPAEvent &p_event, - const std::shared_ptr> &p_renderer) -{ - std::visit( - [p_renderer](const Event &event) { - if constexpr (std::is_same_v) { - p_renderer->Render(event.profile, "iter-" + lexical_cast(event.iteration)); - } - }, - p_event); -} - -} // namespace - -extern std::vector> ReadStrategyPerturbations(const Game &p_game, - std::istream &p_stream); - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria using iterated polymatrix approximation\n"; - p_stream << "Gametracer version 0.2, Copyright (C) 2002, Ben Blum and Christian Shelton\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - - std::cerr << "Options:\n"; - std::cerr << " -d DECIMALS show equilibria as floating point with DECIMALS digits\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -n COUNT number of perturbation vectors to generate randomly\n"; - std::cerr << " (mutually exclusive with -s)\n"; - std::cerr << " -R SEED seed the random number generator used to generate\n"; - std::cerr << " perturbation vectors (default is to seed from system\n"; - std::cerr << " entropy); requires -n\n"; - std::cerr << " -s FILE file containing perturbation vectors\n"; - std::cerr << " (mutually exclusive with -n)\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -V, --verbose verbose mode (shows intermediate output)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - bool quiet = false, verbose = false, numVectorsSet = false; - int numDecimals = 6, numVectors = 1; - std::string startFile; - std::optional seed; - - int long_opt_index = 0; - option long_options[] = {{"help", 0, nullptr, 'h'}, - {"version", 0, nullptr, 'v'}, - {"verbose", 0, nullptr, 'V'}, - {nullptr, 0, nullptr, 0}}; - int c; - while ((c = getopt_long(argc, argv, "d:n:s:R:vVqh", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'q': - quiet = true; - break; - case 'V': - verbose = true; - break; - case 'd': - numDecimals = atoi(optarg); - break; - case 'n': - numVectors = atoi(optarg); - numVectorsSet = true; - break; - case 'R': - seed = std::strtoul(optarg, nullptr, 10); - break; - case 's': - startFile = optarg; - break; - case 'h': - PrintHelp(argv[0]); - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - if (numVectorsSet && !startFile.empty()) { - std::cerr << "Error: The -n and -s options are mutually exclusive.\n"; - return 1; - } - if (seed && !numVectorsSet) { - std::cerr << "Error: The -R option requires -n.\n"; - return 1; - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - auto renderer = MakeMixedStrategyProfileRenderer(std::cout, numDecimals, false); - - std::vector> perts; - if (!startFile.empty()) { - std::ifstream startPerts(startFile.c_str()); - perts = ReadStrategyPerturbations(game, startPerts); - } - else { - // Generate the desired number of points randomly - auto engine = MakeRandomEngine(seed); - perts = NewRandomStrategyProfiles(game, numVectors, engine); - } - - for (auto pert : perts) { - IPAStrategySolve( - pert, - [renderer](const MixedStrategyProfile &p_profile) { - renderer->Render(p_profile); - }, - [renderer, verbose](const IPAEvent &p_event) { - if (verbose) { - RenderIPAEvent(p_event, renderer); - } - }); - } - return 0; - } - catch (std::exception &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/lcp/lcp.cc b/src/tools/lcp/lcp.cc deleted file mode 100644 index 37de3ed05e..0000000000 --- a/src/tools/lcp/lcp.cc +++ /dev/null @@ -1,188 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/lcp/lcp.cc -// Compute Nash equilibria via linear complementarity program -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include -#include -#include -#include -#include -#include "games.h" -#include "tools/util.h" -#include "solvers/lcp/lcp.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria by solving a linear complementarity program\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - std::cerr << "For an extensive game, computes one equilibrium via the sequence form.\n"; - std::cerr << "For a strategic game (or with -S), with no options, reports all\n"; - std::cerr << "accessible Nash equilibria.\n\n"; - - std::cerr << "Options:\n"; - std::cerr << " -d DECIMALS compute using floating-point arithmetic;\n"; - std::cerr << " display results with DECIMALS digits\n"; - std::cerr << " -S use strategic game\n"; - std::cerr << " -e EQA terminate after finding EQA equilibria\n"; - std::cerr << " (strategic games only; default is to find all\n"; - std::cerr << " accessible equilibria)\n"; - std::cerr << " -r DEPTH terminate recursion at DEPTH\n"; - std::cerr << " (strategic games only; only if number of\n"; - std::cerr << " equilibria sought is not 1)\n"; - std::cerr << " -D print detailed information about equilibria\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - int c; - bool useFloat = false, useStrategic = false, quiet = false; - bool printDetail = false; - int numDecimals = 6, maxDepth = 0; - std::optional stopAfter; - - int long_opt_index = 0; - option long_options[] = { - {"help", 0, nullptr, 'h'}, {"version", 0, nullptr, 'v'}, {nullptr, 0, nullptr, 0}}; - while ((c = getopt_long(argc, argv, "d:DvhqSe:r:", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'd': - useFloat = true; - numDecimals = atoi(optarg); - break; - case 'D': - printDetail = true; - break; - case 'e': { - size_t parsed; - const auto *begin = optarg; - const auto *end = optarg + std::strlen(optarg); - const auto result = std::from_chars(begin, end, parsed); - if (result.ec != std::errc{} || result.ptr != end || parsed == 0) { - std::cerr << "Error: -e argument must be a positive integer; got '" << optarg << "'." - << std::endl; - exit(1); - } - stopAfter = parsed; - break; - } - case 'h': - PrintHelp(argv[0]); - break; - case 'q': - quiet = true; - break; - case 'r': - maxDepth = atoi(optarg); - break; - case 'S': - useStrategic = true; - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - if (!game->IsTree() || useStrategic) { - if (useFloat) { - auto renderer = - MakeMixedStrategyProfileRenderer(std::cout, numDecimals, printDetail); - LcpStrategySolve( - game, stopAfter, maxDepth, - [&](const MixedStrategyProfile &p) { renderer->Render(p); }); - } - else { - auto renderer = - MakeMixedStrategyProfileRenderer(std::cout, numDecimals, printDetail); - LcpStrategySolve( - game, stopAfter, maxDepth, - [&](const MixedStrategyProfile &p) { renderer->Render(p); }); - } - } - else { - if (useFloat) { - auto renderer = - MakeMixedBehaviorProfileRenderer(std::cout, numDecimals, printDetail); - LcpBehaviorSolve( - game, [&](const MixedBehaviorProfile &p) { renderer->Render(p); }); - } - else { - auto renderer = - MakeMixedBehaviorProfileRenderer(std::cout, numDecimals, printDetail); - LcpBehaviorSolve( - game, [&](const MixedBehaviorProfile &p) { renderer->Render(p); }); - } - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/liap/liap.cc b/src/tools/liap/liap.cc deleted file mode 100644 index 5e33b13e47..0000000000 --- a/src/tools/liap/liap.cc +++ /dev/null @@ -1,288 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/liap/liap.cc -// Compute Nash equilibria by minimizing Liapunov function -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include -#include "games.h" -#include "tools/util.h" -#include "solvers/liap/liap.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -template -void RenderLiapEvent(const Renderer &p_renderer, const LiapEvent &p_event) -{ - std::visit( - [&](const EventType &event) { - if constexpr (std::is_same_v>) { - p_renderer->Render(event.profile, "start"); - } - else if constexpr (std::is_same_v>) { - p_renderer->Render(event.profile, "end"); - } - }, - p_event); -} - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria by minimizing the Lyapunov function\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - std::cerr << "With no options, attempts to compute one equilibrium starting at centroid.\n"; - - std::cerr << "Options:\n"; - std::cerr << " -A compute agent form equilibria for extensive games\n"; - std::cerr << " -d DECIMALS print probabilities with DECIMALS digits\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -n COUNT number of starting points to generate randomly\n"; - std::cerr << " (mutually exclusive with -s)\n"; - std::cerr << " -R SEED seed the random number generator used to generate\n"; - std::cerr << " starting points (default is to seed from system entropy);\n"; - std::cerr << " requires -n\n"; - std::cerr << " -i MAXITER maximum number of iterations per point (default is 1000)\n"; - std::cerr << " -m MAXREGRET maximum regret acceptable as a proportion of range of\n"; - std::cerr << " payoffs in the game\n"; - std::cerr << " -s FILE file containing starting points\n"; - std::cerr << " (mutually exclusive with -n)\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -V, --verbose verbose mode (shows intermediate output)\n"; - std::cerr << " (default is to only show equilibria)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -std::vector> ReadStrategyProfiles(const Game &p_game, - std::istream &p_stream) -{ - std::vector> profiles; - while (!p_stream.eof() && !p_stream.bad()) { - MixedStrategyProfile p(p_game->NewMixedStrategyProfile(0.0)); - if (p_stream.peek() == EOF) { - break; - } - p_stream >> p[1]; - for (size_t i = 2; i <= p.MixedProfileLength(); i++) { - if (p_stream.eof() || p_stream.bad()) { - break; - } - char comma; - p_stream >> comma; - p_stream >> p[i]; - } - // Read in the rest of the line and discard - std::string foo; - std::getline(p_stream, foo); - profiles.push_back(p); - } - return profiles; -} - -std::vector> ReadBehaviorProfiles(const Game &p_game, - std::istream &p_stream) -{ - std::vector> profiles; - while (!p_stream.eof() && !p_stream.bad()) { - MixedBehaviorProfile p(p_game); - if (p_stream.peek() == EOF) { - break; - } - p_stream >> p[1]; - for (size_t i = 2; i <= p.BehaviorProfileLength(); i++) { - if (p_stream.eof() || p_stream.bad()) { - break; - } - char comma; - p_stream >> comma; - p_stream >> p[i]; - } - // Read in the rest of the line and discard - std::string foo; - std::getline(p_stream, foo); - profiles.push_back(p); - } - return profiles; -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - bool quiet = false, solveAgent = false, verbose = false, numTriesSet = false; - int numTries = 10; - int maxitsN = 1000; - int numDecimals = 6; - double maxregret = 1.0e-4; - std::string startFile; - std::optional seed; - - int long_opt_index = 0; - option long_options[] = {{"help", 0, nullptr, 'h'}, - {"version", 0, nullptr, 'v'}, - {"verbose", 0, nullptr, 'V'}, - {nullptr, 0, nullptr, 0}}; - int c; - while ((c = getopt_long(argc, argv, "d:n:i:s:m:R:hqVvA", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'd': - numDecimals = atoi(optarg); - break; - case 'n': - numTries = atoi(optarg); - numTriesSet = true; - break; - case 'R': - seed = std::strtoul(optarg, nullptr, 10); - break; - case 'm': - maxregret = atof(optarg); - break; - case 'i': - maxitsN = atoi(optarg); - break; - case 's': - startFile = optarg; - break; - case 'h': - PrintHelp(argv[0]); - break; - case 'A': - solveAgent = true; - break; - case 'q': - quiet = true; - break; - case 'V': - verbose = true; - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - if (numTriesSet && !startFile.empty()) { - std::cerr << "Error: The -n and -s options are mutually exclusive.\n"; - return 1; - } - if (seed && !numTriesSet) { - std::cerr << "Error: The -R option requires -n.\n"; - return 1; - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - if (!game->IsTree() || !solveAgent) { - std::vector> starts; - if (!startFile.empty()) { - std::ifstream startPoints(startFile.c_str()); - starts = ReadStrategyProfiles(game, startPoints); - } - else { - // Generate the desired number of points randomly - auto engine = MakeRandomEngine(seed); - starts = NewRandomStrategyProfiles(game, numTries, engine); - } - - for (const auto &start : starts) { - auto renderer = MakeMixedStrategyProfileRenderer(std::cout, numDecimals, false); - LiapStrategySolve( - start, maxregret, maxitsN, - [renderer](const MixedStrategyProfile &p_profile) { - renderer->Render(p_profile); - }, - [renderer, verbose](const LiapEvent> &event) { - if (verbose) { - RenderLiapEvent(renderer, event); - } - }); - } - } - else { - std::vector> starts; - if (!startFile.empty()) { - std::ifstream startPoints(startFile.c_str()); - starts = ReadBehaviorProfiles(game, startPoints); - } - else { - // Generate the desired number of points randomly - auto engine = MakeRandomEngine(seed); - starts = NewRandomBehaviorProfiles(game, numTries, engine); - } - - for (const auto &start : starts) { - auto renderer = MakeMixedBehaviorProfileRenderer(std::cout, numDecimals, false); - LiapAgentSolve( - start, maxregret, maxitsN, - [renderer](const MixedBehaviorProfile &p_profile) { - renderer->Render(p_profile); - }, - [renderer, verbose](const LiapEvent> &event) { - if (verbose) { - RenderLiapEvent(renderer, event); - } - }); - } - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/logit/logit.cc b/src/tools/logit/logit.cc deleted file mode 100644 index 86ee2dad5f..0000000000 --- a/src/tools/logit/logit.cc +++ /dev/null @@ -1,270 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/logit/logit.cc -// Command-line driver program for quantal response equilibrium tracing and -// maximum likelihood estimation. -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include "games.h" -#include "solvers/logit/logit.h" - -using namespace Gambit; - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute a branch of the logit equilibrium correspondence\n"; - p_stream << "Gambit version " VERSION ", "; - p_stream << "Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - - std::cerr << "Options:\n"; - std::cerr << " -d DECIMALS show equilibria as floating point with DECIMALS digits\n"; - std::cerr << " -S use strategic game\n"; - std::cerr << " -s STEP initial stepsize (default is .03)\n"; - std::cerr << " -a ACCEL maximum acceleration (default is 1.1)\n"; - std::cerr << " -m MAXREGRET maximum regret acceptable as a proportion of range of\n"; - std::cerr << " payoffs in the game\n"; - std::cerr << " -l LAMBDA compute QRE at `lambda` accurately\n"; - std::cerr << " -L FILE compute maximum likelihood estimates;\n"; - std::cerr << " read strategy frequencies from FILE\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -e print only the terminal equilibrium\n"; - std::cerr << " (default is to print the entire branch)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -// -// Read in a comma-separated values list of observed data values -// -bool ReadProfile(std::istream &p_stream, MixedStrategyProfile &p_profile) -{ - for (size_t i = 1; i <= p_profile.MixedProfileLength(); i++) { - p_stream >> p_profile[i]; - if (!p_stream) { - return false; - } - if (i < p_profile.MixedProfileLength()) { - char comma; - p_stream >> comma; - if (!p_stream) { - return false; - } - } - } - // Read in the rest of the line and discard - std::string foo; - std::getline(p_stream, foo); - return true; -} - -template -void PrintProfile(std::ostream &p_stream, int p_decimals, const T &p_profile, bool p_nash = false) -{ - if (p_nash) { - p_stream << "NE"; - } - else { - p_stream.setf(std::ios::fixed); - p_stream << std::setprecision(p_decimals) << p_profile.GetLambda(); - p_stream.unsetf(std::ios::fixed); - } - for (size_t i = 1; i <= p_profile.size(); i++) { - p_stream << "," << std::setprecision(p_decimals) << p_profile[i]; - } - if (p_profile.GetLogLike() <= 0.0) { - p_stream.setf(std::ios::fixed); - p_stream << "," << std::setprecision(p_decimals) << p_profile.GetLogLike(); - p_stream.unsetf(std::ios::fixed); - } - p_stream << std::endl; -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - - bool quiet = false, useStrategic = false; - const double maxLambda = 1000000.0; - double maxregret = 1.0e-8; - std::string mleFile; - double maxDecel = 1.1; - double hStart = 0.03; - std::list targetLambda; - bool fullGraph = true; - int decimals = 6; - - int long_opt_index = 0; - option long_options[] = { - {"help", 0, nullptr, 'h'}, {"version", 0, nullptr, 'v'}, {nullptr, 0, nullptr, 0}}; - int c; - while ((c = getopt_long(argc, argv, "d:s:a:m:vqehSL:l:", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'q': - quiet = true; - break; - case 'd': - decimals = atoi(optarg); - break; - case 's': - hStart = atof(optarg); - break; - case 'a': - maxDecel = atof(optarg); - break; - case 'm': - maxregret = atof(optarg); - break; - case 'e': - fullGraph = false; - break; - case 'h': - PrintHelp(argv[0]); - break; - case 'S': - useStrategic = true; - break; - case 'L': - mleFile = optarg; - break; - case 'l': - targetLambda.push_back(atof(optarg)); - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - if (!game->IsPerfectRecall()) { - throw UndefinedException( - "Computing equilibria of games with imperfect recall is not supported."); - } - - if (!mleFile.empty() && (!game->IsTree() || useStrategic)) { - MixedStrategyProfile frequencies(game->NewMixedStrategyProfile(0.0)); - std::ifstream mleData(mleFile.c_str()); - if (!ReadProfile(mleData, frequencies)) { - throw std::runtime_error("Error reading strategy frequencies from '" + mleFile + "'."); - } - - auto printer = [fullGraph, - decimals](const LogitEvent &p_event) { - if (fullGraph) { - PrintProfile(std::cout, decimals, - std::get>(p_event).qre); - } - }; - auto result = - LogitStrategyEstimate(frequencies, maxLambda, 1.0, false, hStart, maxDecel, printer); - PrintProfile(std::cout, decimals, result); - return 0; - } - - if (!game->IsTree() || useStrategic) { - auto printer = [fullGraph, - decimals](const LogitEvent &p_event) { - if (fullGraph) { - PrintProfile(std::cout, decimals, - std::get>(p_event).qre); - } - }; - const LogitQREMixedStrategyProfile start(game); - if (!targetLambda.empty()) { - auto result = - LogitStrategySolveLambda(start, targetLambda, 1.0, hStart, maxDecel, printer); - for (auto &profile : result) { - PrintProfile(std::cout, decimals, profile); - } - } - else { - auto result = LogitStrategySolve(start, maxregret, 1.0, hStart, maxDecel, - Nash::NullStrategyCallback, printer); - PrintProfile(std::cout, decimals, result.back(), true); - } - } - else { - auto printer = [fullGraph, - decimals](const LogitEvent &p_event) { - if (fullGraph) { - PrintProfile(std::cout, decimals, - std::get>(p_event).qre); - } - }; - const LogitQREMixedBehaviorProfile start(game); - if (!targetLambda.empty()) { - auto result = - LogitBehaviorSolveLambda(start, targetLambda, 1.0, hStart, maxDecel, printer); - for (auto &profile : result) { - PrintProfile(std::cout, decimals, profile); - } - } - else { - auto result = LogitBehaviorSolve(start, maxregret, 1.0, hStart, maxDecel, - Nash::NullBehaviorCallback, printer); - PrintProfile(std::cout, decimals, result.back(), true); - } - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/lp/lp.cc b/src/tools/lp/lp.cc deleted file mode 100644 index 1f85fc3de2..0000000000 --- a/src/tools/lp/lp.cc +++ /dev/null @@ -1,158 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/lp/lp.cc -// Implementation of algorithm to compute mixed strategy equilibria -// of constant sum normal form games via linear programming -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include - -#include "games.h" -#include "tools/util.h" -#include "solvers/lp/lp.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria by solving a linear program\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - std::cerr << "With no options, computes one Nash equilibrium.\n\n"; - - std::cerr << "Options:\n"; - std::cerr << " -d DECIMALS compute using floating-point arithmetic;\n"; - std::cerr << " display results with DECIMALS digits\n"; - std::cerr << " -S use strategic game\n"; - std::cerr << " -D print detailed information about equilibria\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - int c; - int numDecimals = 6; - bool useFloat = false, useStrategic = false, quiet = false, printDetail = false; - - int long_opt_index = 0; - option long_options[] = { - {"help", 0, nullptr, 'h'}, {"version", 0, nullptr, 'v'}, {nullptr, 0, nullptr, 0}}; - while ((c = getopt_long(argc, argv, "d:DvqhS", long_options, &long_opt_index)) != -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'd': - useFloat = true; - numDecimals = atoi(optarg); - break; - case 'D': - printDetail = true; - break; - case 'h': - PrintHelp(argv[0]); - break; - case 'q': - quiet = true; - break; - case 'S': - useStrategic = true; - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - if (!game->IsTree() || useStrategic) { - if (useFloat) { - auto renderer = - MakeMixedStrategyProfileRenderer(std::cout, numDecimals, printDetail); - LpStrategySolve( - game, [&](const MixedStrategyProfile &p) { renderer->Render(p); }); - } - else { - auto renderer = - MakeMixedStrategyProfileRenderer(std::cout, numDecimals, printDetail); - LpStrategySolve( - game, [&](const MixedStrategyProfile &p) { renderer->Render(p); }); - } - } - else { - if (useFloat) { - auto renderer = - MakeMixedBehaviorProfileRenderer(std::cout, numDecimals, printDetail); - LpBehaviorSolve( - game, [&](const MixedBehaviorProfile &p) { renderer->Render(p); }); - } - else { - auto renderer = - MakeMixedBehaviorProfileRenderer(std::cout, numDecimals, printDetail); - LpBehaviorSolve( - game, [&](const MixedBehaviorProfile &p) { renderer->Render(p); }); - } - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/simpdiv/nfgsimpdiv.cc b/src/tools/simpdiv/nfgsimpdiv.cc deleted file mode 100644 index 4408b8ce2f..0000000000 --- a/src/tools/simpdiv/nfgsimpdiv.cc +++ /dev/null @@ -1,277 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/simpdiv/nfgsimpdiv.cc -// Compute Nash equilibria via simplicial subdivision on the normal form -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include -#include -#include -#include -#include "games.h" -#include "tools/util.h" -#include "solvers/simpdiv/simpdiv.h" - -using namespace Gambit; -using namespace Gambit::Nash; - -template -void RenderSimpdivEvent(const Renderer &p_renderer, const SimpdivEvent &p_event) -{ - std::visit( - [&](const EventType &event) { - if constexpr (std::is_same_v) { - p_renderer->Render(event.profile, "start"); - } - else if constexpr (std::is_same_v) { - p_renderer->Render(event.profile, std::to_string(event.gridSize)); - } - }, - p_event); -} - -std::vector> ReadProfiles(const Game &p_game, - std::istream &p_stream) -{ - std::vector> profiles; - while (!p_stream.eof() && !p_stream.bad()) { - MixedStrategyProfile p(p_game->NewMixedStrategyProfile(Rational(0))); - p_stream >> p[1]; - if (!p_stream) { - break; - } - for (size_t i = 2; i <= p.MixedProfileLength(); i++) { - if (p_stream.eof() || p_stream.bad()) { - break; - } - char comma; - p_stream >> comma; - p_stream >> p[i]; - } - // Read in the rest of the line and discard - std::string foo; - std::getline(p_stream, foo); - profiles.push_back(p); - } - return profiles; -} - -class MixedStrategyCSVAsFloatRenderer final : public MixedStrategyProfileRenderer { -public: - explicit MixedStrategyCSVAsFloatRenderer(std::ostream &p_stream, int p_numDecimals = 6) - : m_stream(p_stream), m_numDecimals(p_numDecimals) - { - } - ~MixedStrategyCSVAsFloatRenderer() override = default; - void Render(const MixedStrategyProfile &p_profile, - const std::string &p_label = "NE") const override; - -private: - std::ostream &m_stream; - int m_numDecimals; -}; - -void MixedStrategyCSVAsFloatRenderer::Render(const MixedStrategyProfile &p_profile, - const std::string &p_label) const -{ - m_stream << p_label; - for (size_t i = 1; i <= p_profile.MixedProfileLength(); i++) { - m_stream << "," << lexical_cast(double(p_profile[i]), m_numDecimals); - } - m_stream << std::endl; -} - -void PrintBanner(std::ostream &p_stream) -{ - p_stream << "Compute Nash equilibria using simplicial subdivision\n"; - p_stream << "Gambit version " VERSION ", Copyright (C) 1994-2026, The Gambit Project\n"; - p_stream << "This is free software, distributed under the GNU GPL\n\n"; -} - -void PrintHelp(char *progname) -{ - PrintBanner(std::cerr); - std::cerr << "Usage: " << progname << " [OPTIONS] [file]\n"; - std::cerr << "If file is not specified, attempts to read game from standard input.\n"; - std::cerr << "With no options, computes one approximate Nash equilibrium.\n\n"; - - std::cerr << "Options:\n"; - std::cerr << " -g MULT granularity of grid refinement at each step (default is 2)\n"; - std::cerr << " -h, --help print this help message\n"; - std::cerr << " -r DENOM generate random starting points with denominator DENOM\n"; - std::cerr << " (mutually exclusive with -s)\n"; - std::cerr << " -n COUNT number of starting points to generate (requires -r)\n"; - std::cerr << " -R SEED seed the random number generator used to generate\n"; - std::cerr << " starting points (default is to seed from system entropy);\n"; - std::cerr << " requires -n\n"; - std::cerr << " -s FILE file containing starting points\n"; - std::cerr << " (mutually exclusive with -r)\n"; - std::cerr << " -d DECIMALS show profiles as floating point with DECIMALS digits\n"; - std::cerr << " (default is to display rational numbers)\n"; - std::cerr << " -m MAXREGRET maximum regret acceptable as a proportion of range of\n"; - std::cerr << " payoffs in the game (default is 1e-7)\n"; - std::cerr << " -q quiet mode (suppresses banner)\n"; - std::cerr << " -V, --verbose verbose mode (shows intermediate output)\n"; - std::cerr << " (default is to only show equilibria)\n"; - std::cerr << " -v, --version print version information\n"; - exit(0); -} - -int main(int argc, char *argv[]) -{ - opterr = 0; - std::string startFile; - bool useRandom = false, numTriesSet = false; - int randDenom = 1, gridResize = 2, stopAfter = 1, decimals = 0; - bool verbose = false, quiet = false; - Rational maxregret(1, 10000000); - std::optional seed; - - int long_opt_index = 0; - struct option long_options[] = {{"help", 0, nullptr, 'h'}, - {"version", 0, nullptr, 'v'}, - {"verbose", 0, nullptr, 'V'}, - {nullptr, 0, nullptr, 0}}; - int c; - while ((c = getopt_long(argc, argv, "g:hVvn:r:s:m:d:R:q", long_options, &long_opt_index)) != - -1) { - switch (c) { - case 'v': - PrintBanner(std::cerr); - exit(0); - case 'g': - gridResize = atoi(optarg); - break; - case 'h': - PrintHelp(argv[0]); - break; - case 'r': - useRandom = true; - randDenom = atoi(optarg); - break; - case 'n': - stopAfter = atoi(optarg); - numTriesSet = true; - break; - case 'R': - seed = std::strtoul(optarg, nullptr, 10); - break; - case 'm': - maxregret = lexical_cast(std::string(optarg)); - break; - case 'd': - decimals = atoi(optarg); - break; - case 's': - startFile = optarg; - break; - case 'q': - quiet = true; - break; - case 'V': - verbose = true; - break; - case '?': - if (isprint(optopt)) { - std::cerr << argv[0] << ": Unknown option `-" << static_cast(optopt) << "'.\n"; - } - else { - std::cerr << argv[0] << ": Unknown option character `\\x" << optopt << "`.\n"; - } - return 1; - default: - abort(); - } - } - - if (!quiet) { - PrintBanner(std::cerr); - } - - if (useRandom && !startFile.empty()) { - std::cerr << "Error: The -r and -s options are mutually exclusive.\n"; - return 1; - } - if (numTriesSet && !useRandom) { - std::cerr << "Error: The -n option requires -r.\n"; - return 1; - } - if (seed && !numTriesSet) { - std::cerr << "Error: The -R option requires -n.\n"; - return 1; - } - - std::istream *input_stream = &std::cin; - std::ifstream file_stream; - if (optind < argc) { - file_stream.open(argv[optind]); - if (!file_stream.is_open()) { - std::ostringstream error_message; - error_message << argv[0] << ": " << argv[optind]; - perror(error_message.str().c_str()); - exit(1); - } - input_stream = &file_stream; - } - - try { - const Game game = ReadGame(*input_stream); - std::vector> starts; - if (!startFile.empty()) { - std::ifstream startPoints(startFile.c_str()); - starts = ReadProfiles(game, startPoints); - } - else if (useRandom) { - auto engine = MakeRandomEngine(seed); - starts = NewRandomStrategyProfiles(game, stopAfter, randDenom, engine); - } - else { - starts.push_back(SimpdivDefaultStart(game)); - } - for (auto start : starts) { - if (decimals > 0) { - auto renderer = std::make_shared(std::cout, decimals); - SimpdivStrategySolve( - start, maxregret, gridResize, 0, - [&](const MixedStrategyProfile &p) { renderer->Render(p); }, - [&](const SimpdivEvent &event) { - if (verbose) { - RenderSimpdivEvent(renderer, event); - } - }); - } - else { - auto renderer = std::make_shared>(std::cout); - SimpdivStrategySolve( - start, maxregret, gridResize, 0, - [&](const MixedStrategyProfile &p) { renderer->Render(p); }, - [&](const SimpdivEvent &event) { - if (verbose) { - RenderSimpdivEvent(renderer, event); - } - }); - } - } - return 0; - } - catch (std::runtime_error &e) { - std::cerr << "Error: " << e.what() << std::endl; - return 1; - } -} diff --git a/src/tools/util.h b/src/tools/util.h deleted file mode 100644 index f027297da8..0000000000 --- a/src/tools/util.h +++ /dev/null @@ -1,293 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/tools/util.h -// Utility functions common to command-line tools -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#ifndef TOOLS_UTIL_H -#define TOOLS_UTIL_H - -#include -#include -#include "games/stratmixed.h" -#include "games/behavmixed.h" - -namespace Gambit { - -/// @brief Constructs a random engine for generating starting points for command-line tools. -/// If p_seed is given, the engine is seeded deterministically with it, so that the run -/// can be reproduced; otherwise, the engine is seeded from system entropy. -inline std::default_random_engine MakeRandomEngine(std::optional p_seed) -{ - if (p_seed) { - return std::default_random_engine(*p_seed); - } - std::random_device rd; - return std::default_random_engine(rd()); -} - -template class MixedProfileRenderer { -public: - virtual ~MixedProfileRenderer() = default; - virtual void Render(const MixedStrategyProfile &p_profile, - const std::string &p_label = "NE") const = 0; - virtual void Render(const MixedBehaviorProfile &p_profile, - const std::string &p_label = "NE") const = 0; -}; - -// -// Encapsulates the rendering of a mixed strategy profile to various text formats. -// Implements automatic conversion of behavior strategy profiles to mixed -// strategy profiles. -// -template class MixedStrategyProfileRenderer : public MixedProfileRenderer { -public: - ~MixedStrategyProfileRenderer() override = default; - void Render(const MixedStrategyProfile &p_profile, - const std::string &p_label = "NE") const override = 0; - void Render(const MixedBehaviorProfile &p_profile, - const std::string &p_label = "NE") const override - { - Render(p_profile.ToMixedProfile(), p_label); - } -}; - -template -class MixedStrategyProfileCSVRenderer final : public MixedStrategyProfileRenderer { -public: - explicit MixedStrategyProfileCSVRenderer(std::ostream &p_stream, int p_numDecimals = 6) - : m_stream(p_stream), m_numDecimals(p_numDecimals) - { - } - ~MixedStrategyProfileCSVRenderer() override = default; - void Render(const MixedStrategyProfile &p_profile, - const std::string &p_label = "NE") const override; - -private: - std::ostream &m_stream; - int m_numDecimals; -}; - -template -class MixedStrategyProfileDetailRenderer final : public MixedStrategyProfileRenderer { -public: - explicit MixedStrategyProfileDetailRenderer(std::ostream &p_stream, int p_numDecimals = 6) - : m_stream(p_stream), m_numDecimals(p_numDecimals) - { - } - ~MixedStrategyProfileDetailRenderer() override = default; - void Render(const MixedStrategyProfile &p_profile, - const std::string &p_label = "NE") const override; - -private: - std::ostream &m_stream; - int m_numDecimals; -}; - -template -std::shared_ptr> -MakeMixedStrategyProfileRenderer(std::ostream &p_stream, int p_numDecimals, bool p_printDetail) -{ - if (p_printDetail) { - return std::make_shared>(p_stream, p_numDecimals); - } - return std::make_shared>(p_stream, p_numDecimals); -} - -// -// Encapsulates the rendering of a mixed behavior profile to various text formats. -// -template class MixedBehaviorProfileRenderer : public MixedProfileRenderer { -public: - ~MixedBehaviorProfileRenderer() override = default; - void Render(const MixedStrategyProfile &p_profile, - const std::string &p_label = "NE") const override - { - Render(MixedBehaviorProfile(p_profile), p_label); - } - void Render(const MixedBehaviorProfile &p_profile, - const std::string &p_label = "NE") const override = 0; -}; - -template -class MixedBehaviorProfileCSVRenderer final : public MixedBehaviorProfileRenderer { -public: - explicit MixedBehaviorProfileCSVRenderer(std::ostream &p_stream, int p_numDecimals = 6) - : m_stream(p_stream), m_numDecimals(p_numDecimals) - { - } - ~MixedBehaviorProfileCSVRenderer() override = default; - void Render(const MixedBehaviorProfile &p_profile, - const std::string &p_label = "NE") const override; - -private: - std::ostream &m_stream; - int m_numDecimals; -}; - -template -class MixedBehaviorProfileDetailRenderer final : public MixedBehaviorProfileRenderer { -public: - explicit MixedBehaviorProfileDetailRenderer(std::ostream &p_stream, int p_numDecimals = 6) - : m_stream(p_stream), m_numDecimals(p_numDecimals) - { - } - ~MixedBehaviorProfileDetailRenderer() override = default; - void Render(const MixedBehaviorProfile &p_profile, - const std::string &p_label = "NE") const override; - -private: - std::ostream &m_stream; - int m_numDecimals; -}; - -template -std::shared_ptr> -MakeMixedBehaviorProfileRenderer(std::ostream &p_stream, int p_numDecimals, bool p_printDetail) -{ - if (p_printDetail) { - return std::make_shared>(p_stream, p_numDecimals); - } - return std::make_shared>(p_stream, p_numDecimals); -} - -template -void MixedStrategyProfileCSVRenderer::Render(const MixedStrategyProfile &p_profile, - const std::string &p_label) const -{ - m_stream << p_label; - for (size_t i = 1; i <= p_profile.MixedProfileLength(); i++) { - m_stream << "," << lexical_cast(p_profile[i], m_numDecimals); - } - m_stream << std::endl; -} - -template -void MixedStrategyProfileDetailRenderer::Render(const MixedStrategyProfile &p_profile, - const std::string &p_label) const -{ - for (auto player : p_profile.GetGame()->GetPlayers()) { - m_stream << "Strategy profile for player " << player->GetNumber() << ":\n"; - - m_stream << "Strategy Prob Value\n"; - m_stream << "-------- ----------- -----------\n"; - - for (auto strategy : player->GetStrategies()) { - if (!strategy->GetLabel().empty()) { - m_stream << std::setw(8) << strategy->GetLabel() << " "; - } - else { - m_stream << std::setw(8) << strategy->GetNumber() << " "; - } - m_stream << std::setw(10); - m_stream << lexical_cast(p_profile[strategy], m_numDecimals); - m_stream << " "; - m_stream << std::setw(11); - m_stream << lexical_cast(p_profile.GetPayoff(strategy), m_numDecimals); - m_stream << std::endl; - } - } -} - -template -void MixedBehaviorProfileCSVRenderer::Render(const MixedBehaviorProfile &p_profile, - const std::string &p_label) const -{ - m_stream << p_label; - for (size_t i = 1; i <= p_profile.BehaviorProfileLength(); i++) { - m_stream << "," << lexical_cast(p_profile[i], m_numDecimals); - } - m_stream << std::endl; -} - -template -void MixedBehaviorProfileDetailRenderer::Render(const MixedBehaviorProfile &p_profile, - const std::string &p_label) const -{ - for (auto player : p_profile.GetGame()->GetPlayers()) { - m_stream << "Behavior profile for player " << player->GetNumber() << ":\n"; - - m_stream << "Infoset Action Prob Value\n"; - m_stream << "------- ------- ----------- -----------\n"; - - for (const auto &infoset : player->GetInfosets()) { - for (const auto &action : infoset->GetActions()) { - if (!infoset->GetLabel().empty()) { - m_stream << std::setw(7) << infoset->GetLabel() << " "; - } - else { - m_stream << std::setw(7) << infoset->GetNumber() << " "; - } - if (!action->GetLabel().empty()) { - m_stream << std::setw(7) << action->GetLabel() << " "; - } - else { - m_stream << std::setw(7) << action->GetNumber() << " "; - } - m_stream << std::setw(11); - m_stream << lexical_cast(p_profile[action], m_numDecimals); - m_stream << " "; - m_stream << std::setw(11); - std::optional actionValue = p_profile.GetPayoff(action); - if (actionValue.has_value()) { - m_stream << lexical_cast(actionValue.value(), m_numDecimals); - } - m_stream << std::endl; - } - } - - m_stream << std::endl; - m_stream << "Infoset Node Belief Prob\n"; - m_stream << "------- ------- ----------- -----------\n"; - - for (const auto &infoset : player->GetInfosets()) { - for (const auto &node : infoset->GetMembers()) { - if (!infoset->GetLabel().empty()) { - m_stream << std::setw(7) << infoset->GetLabel() << " "; - } - else { - m_stream << std::setw(7) << infoset->GetNumber() << " "; - } - if (!node->GetLabel().empty()) { - m_stream << std::setw(7) << node->GetLabel() << " "; - } - else { - m_stream << std::setw(7) << node->GetNumber() << " "; - } - m_stream << std::setw(11); - auto belief = p_profile.GetBeliefProb(node); - if (belief.has_value()) { - m_stream << lexical_cast(belief.value(), m_numDecimals); - } - else { - m_stream << ""; - } - m_stream << " "; - m_stream << std::setw(11); - m_stream << lexical_cast(p_profile.GetRealizProb(node), m_numDecimals); - m_stream << std::endl; - } - } - m_stream << std::endl; - } -} - -} // namespace Gambit - -#endif // TOOLS_UTIL_H From c82d0e4e6d29085a833b6b65aa5bb6c015fc6ec7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 26 Aug 2026 16:13:40 +0100 Subject: [PATCH 06/11] Mention command-line tools are installed via pygambit. --- doc/index.rst | 2 +- doc/pygambit.rst | 4 +++- doc/tools.rst | 12 ++++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index c4fa8119dc..ad354146a0 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -69,7 +69,7 @@ construction and analysis of finite extensive and strategic games. .. grid-item-card:: 💻 Command-line interface :columns: 3 - Use Gambit's command-line tools for scripting. + Run equilibrium calculations from the command-line. .. button-ref:: command-line :ref-type: ref diff --git a/doc/pygambit.rst b/doc/pygambit.rst index ce251cb5bf..1c8f0f7723 100644 --- a/doc/pygambit.rst +++ b/doc/pygambit.rst @@ -6,12 +6,14 @@ PyGambit See installation instructions in the :ref:`install` section. - For newcomers to Gambit, we recommend reading through the PyGambit tutorials, which demonstrate the API's key capabilities for analyzing and solving games. All of these tutorials assume a basic knowledge of programming in Python. You can run the tutorials interactively as Jupyter notebooks, see :ref:`local_tutorials`. +Installing PyGambit also installs Gambit's :ref:`command-line tools ` +for computing Nash equilibria from the shell or from scripts. + New user tutorials ------------------ diff --git a/doc/tools.rst b/doc/tools.rst index ed0ce7a5d9..2efab1a955 100644 --- a/doc/tools.rst +++ b/doc/tools.rst @@ -4,14 +4,22 @@ CLI *** -These programs are installed as part of :ref:`PyGambit `. - Gambit provides command-line interfaces for each method for computing Nash equilibria. These are suitable for scripting or calling from other programs. This chapter describes the use of these programs. For a general overview of methods for computing equilibria, see the survey of :cite:p:`McKMcL96`. +.. versionchanged:: 17.0.0 + + These programs are now implemented in Python, as part of the + :ref:`pygambit ` package, rather than being separate + C++ executables. Installing PyGambit (``pip install pygambit``, + see :ref:`install`) installs these command-line programs alongside + it; no separate download or build is needed. Each program's + command-line interface, options, and output are unchanged from + the previous C++ versions, except as otherwise noted. + These programs take an extensive or strategic game file, which can be specified on the command line or piped via standard input, and output a list of equilibria computed. The default output format is to present From db1d8ba29dc2ca16a6a869be2e575fec9e486ee3 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 26 Aug 2026 16:14:25 +0100 Subject: [PATCH 07/11] ChangeLog --- ChangeLog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog b/ChangeLog index 19c2093797..ab68da1dfe 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,6 +6,8 @@ is found or another solver event is emitted. ### Changed +- Command-line tools are now implemented in `pygambit` rather than C++-based compiled programs. + The `gambit-convert` utility has been removed. - Revised `StrategySupportProfile` to expose a slimmer set of operations, and implement `StrategySupport` as an immutable snapshot. From f6da89ddc52bf642b8d53f8fc0938abe8823d51d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 27 Aug 2026 20:02:33 +0100 Subject: [PATCH 08/11] Read profiles from CSV as exact numbers, so rationals are now supported for free --- src/pygambit/cli/common.py | 22 +++++++++++++--------- src/pygambit/cli/simpdiv.py | 2 +- tests/cli/test_gnm.py | 13 +++++++++++++ tests/cli/test_ipa.py | 9 +++++++++ tests/cli/test_liap.py | 24 ++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index acf1515e01..e603f0a9d7 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -318,13 +318,13 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> def read_strategy_profiles_csv( path: str, game: gbt.Game, - rational: bool = False, -) -> list[gbt.MixedStrategyProfile]: +) -> list[gbt.MixedStrategyProfileRational]: """Read one mixed strategy profile per line from `path`, each a flat comma-separated list of probabilities in the same order as a profile's CSV row (excluding any leading label), matching the C++ tools' `ReadStrategyProfiles`/`ReadProfiles`. + Values are parsed as exact rationals; a method which requires floating-point + starting points converts the result via `~MixedStrategyProfile.as_float`. """ - convert = gbt.Rational if rational else float strategies = [strategy for player in game.players for strategy in player.strategies] profiles = [] for line in pathlib.Path(path).read_text().splitlines(): @@ -333,20 +333,24 @@ def read_strategy_profiles_csv( continue fields = line.split(",") try: - values = iter([convert(fields[i]) for i in range(len(strategies))]) + values = iter([gbt.Rational(fields[i]) for i in range(len(strategies))]) except (ValueError, IndexError) as exc: raise ValueError(f"Error reading strategy profile from '{path}': {exc}") from None - profile = game.mixed_strategy_profile(rational=rational) + profile = game.mixed_strategy_profile(rational=True) for player in game.players: profile[player.label] = {s.label: next(values) for s in player.strategies} profiles.append(profile) return profiles -def read_behavior_profiles_csv(path: str, game: gbt.Game) -> list[gbt.MixedBehaviorProfileDouble]: +def read_behavior_profiles_csv( + path: str, game: gbt.Game +) -> list[gbt.MixedBehaviorProfileRational]: """Read one mixed behavior profile per line from `path`, each a flat comma-separated list of probabilities in the same order as a profile's CSV row (excluding any - leading label), matching the C++ tools' `ReadBehaviorProfiles`. + leading label), matching the C++ tools' `ReadBehaviorProfiles`. Values are parsed + as exact rationals; a method which requires floating-point starting points converts + the result via `~MixedBehaviorProfile.as_float`. """ count = sum( len(list(infoset.actions)) for player in game.players for infoset in player.infosets @@ -358,10 +362,10 @@ def read_behavior_profiles_csv(path: str, game: gbt.Game) -> list[gbt.MixedBehav continue fields = line.split(",") try: - values = iter([float(fields[i]) for i in range(count)]) + values = iter([gbt.Rational(fields[i]) for i in range(count)]) except (ValueError, IndexError) as exc: raise ValueError(f"Error reading behavior profile from '{path}': {exc}") from None - profile = game.mixed_behavior_profile() + profile = game.mixed_behavior_profile(rational=True) for player in game.players: for infoset in player.infosets: node = next(iter(infoset.members)) diff --git a/src/pygambit/cli/simpdiv.py b/src/pygambit/cli/simpdiv.py index 67b48a5dd6..1f929e94e7 100644 --- a/src/pygambit/cli/simpdiv.py +++ b/src/pygambit/cli/simpdiv.py @@ -151,7 +151,7 @@ def main( as_float = decimals > 0 if start_file is not None: - starts = read_strategy_profiles_csv(start_file, game, rational=True) + starts = read_strategy_profiles_csv(start_file, game) elif rand_denom is not None: starts = [ game.random_strategy_profile(denom=rand_denom, gen=np.random.default_rng(seed)) diff --git a/tests/cli/test_gnm.py b/tests/cli/test_gnm.py index 2abed2632a..3313210942 100644 --- a/tests/cli/test_gnm.py +++ b/tests/cli/test_gnm.py @@ -22,6 +22,19 @@ def test_default_finds_equilibria_along_the_path(cli_runner, nfg_asymmetric_tabl ] +def test_starting_file_accepts_exact_fractions(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path, "9/10,1/10,9/10,1/10\n") + result = cli_runner.invoke( + gnm.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip().splitlines() == [ + "NE,1.000000,0.000000,1.000000,0.000000", + "NE,0.750000,0.250000,0.250000,0.750000", + "NE,0.000000,1.000000,0.000000,1.000000", + ] + + def test_decimals_flag_changes_precision(cli_runner, nfg_asymmetric_table_text, tmp_path): start_file = _start_file(tmp_path) result = cli_runner.invoke( diff --git a/tests/cli/test_ipa.py b/tests/cli/test_ipa.py index b0c58c5933..f837d883ab 100644 --- a/tests/cli/test_ipa.py +++ b/tests/cli/test_ipa.py @@ -18,6 +18,15 @@ def test_default_finds_an_equilibrium(cli_runner, nfg_asymmetric_table_text, tmp assert result.stdout.strip() == "NE,0.750000,0.250000,0.250000,0.750000" +def test_starting_file_accepts_exact_fractions(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path, "9/10,1/10,9/10,1/10\n") + result = cli_runner.invoke( + ipa.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.750000,0.250000,0.250000,0.750000" + + def test_decimals_flag_changes_precision(cli_runner, nfg_asymmetric_table_text, tmp_path): start_file = _start_file(tmp_path) result = cli_runner.invoke( diff --git a/tests/cli/test_liap.py b/tests/cli/test_liap.py index 6bcc8d42dc..fe1dd8a21d 100644 --- a/tests/cli/test_liap.py +++ b/tests/cli/test_liap.py @@ -15,6 +15,16 @@ def test_default_finds_an_equilibrium_from_a_starting_file( assert result.stdout.strip() == "NE,0.750000,0.250000,0.250000,0.750000" +def test_starting_file_accepts_exact_fractions(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = tmp_path / "start.csv" + start_file.write_text("1/2,1/2,1/2,1/2\n") + result = cli_runner.invoke( + liap.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "NE,0.750000,0.250000,0.250000,0.750000" + + def test_verbose_flag_adds_a_start_line(cli_runner, nfg_asymmetric_table_text, tmp_path): start_file = tmp_path / "start.csv" start_file.write_text("0.5,0.5,0.5,0.5\n") @@ -88,6 +98,20 @@ def test_agent_flag_reports_behavior_form_on_a_tree_game( assert all(len(line.split(",")) == 8 for line in lines) +def test_agent_flag_starting_file_accepts_exact_fractions( + cli_runner, efg_asymmetric_tree_text, tmp_path +): + start_file = tmp_path / "start.csv" + start_file.write_text("1/2,1/2,3/10,3/10,2/5,1/2,1/2\n") + result = cli_runner.invoke( + liap.main, ["-q", "-A", "-s", str(start_file)], input=efg_asymmetric_tree_text + ) + assert result.exit_code == 0 + lines = result.stdout.strip().splitlines() + assert lines + assert all(len(line.split(",")) == 8 for line in lines) + + def test_agent_flag_has_no_effect_on_a_table_game(cli_runner, nfg_asymmetric_table_text): without = cli_runner.invoke( liap.main, ["-q", "-n", "3", "-R", "1"], input=nfg_asymmetric_table_text From 160ffe97193deb1fbc108fd2ab0aa038679a2241 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 27 Aug 2026 20:25:29 +0100 Subject: [PATCH 09/11] Adding missing file from Makefile --- Makefile.am | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile.am b/Makefile.am index fd0f6f6345..0e0eb8f556 100644 --- a/Makefile.am +++ b/Makefile.am @@ -25,6 +25,7 @@ ACLOCAL_AMFLAGS = -I m4 include build_support/catalog/catalog.am EXTRA_DIST = \ + build_support/GAMBIT_VERSION \ build_support/msw/gambit.wxs.in \ build_support/msw/banner.bmp \ build_support/msw/dialog.bmp \ @@ -337,7 +338,7 @@ enumpoly_SOURCES = \ src/solvers/enumpoly/nfgpoly.cc \ src/solvers/enumpoly/enumpoly.h -noinst_HEADERS = src/gambit.h src/solvers.h src/solvers/nash.h +noinst_HEADERS = src/gambit.h src/solvers.h src/solvers/nash.h src/solvers/enumpure/enumpure.h if IS_WIN32 AM_LDFLAGS = -static -static-libgcc -static-libstdc++ From 692c97372565830ab604553fd63256a77a37b8a1 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 27 Aug 2026 20:34:47 +0100 Subject: [PATCH 10/11] Fix new command-line tests for MSW --- tests/cli/test_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/test_common.py b/tests/cli/test_common.py index ae6070034f..240ca80d86 100644 --- a/tests/cli/test_common.py +++ b/tests/cli/test_common.py @@ -50,7 +50,7 @@ def test_dash_returns_stdin(self): def test_valid_path_is_opened(self, tmp_path): path = tmp_path / "game.nfg" - path.write_text("NFG 1 R\n") + path.write_bytes(b"NFG 1 R\n") f = common.open_game_file(str(path), "prog") try: assert f.read() == b"NFG 1 R\n" From c805e7b455d854114b4e867017dfa8816f96bfb6 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 28 Aug 2026 09:49:05 +0100 Subject: [PATCH 11/11] Adjust liap CLI test --- tests/cli/test_liap.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/cli/test_liap.py b/tests/cli/test_liap.py index fe1dd8a21d..882cfecf2f 100644 --- a/tests/cli/test_liap.py +++ b/tests/cli/test_liap.py @@ -89,8 +89,14 @@ def test_agent_flag_reports_behavior_form_on_a_tree_game( ): start_file = tmp_path / "start.csv" start_file.write_text("0.5,0.5,0.3,0.3,0.4,0.5,0.5\n") + # This starting point sits close enough to a flat spot in the Lyapunov function + # (the "y"/"z" actions are payoff-irrelevant ties) that whether minimization + # reaches the default 1e-4 maxregret within maxiter is sensitive to + # platform-dependent floating-point rounding; loosen -m so the test checks CLI + # plumbing rather than exact convergence behavior. result = cli_runner.invoke( - liap.main, ["-q", "-A", "-s", str(start_file)], input=efg_asymmetric_tree_text + liap.main, ["-q", "-A", "-m", "0.01", "-s", str(start_file)], + input=efg_asymmetric_tree_text, ) assert result.exit_code == 0 lines = result.stdout.strip().splitlines() @@ -104,7 +110,8 @@ def test_agent_flag_starting_file_accepts_exact_fractions( start_file = tmp_path / "start.csv" start_file.write_text("1/2,1/2,3/10,3/10,2/5,1/2,1/2\n") result = cli_runner.invoke( - liap.main, ["-q", "-A", "-s", str(start_file)], input=efg_asymmetric_tree_text + liap.main, ["-q", "-A", "-m", "0.01", "-s", str(start_file)], + input=efg_asymmetric_tree_text, ) assert result.exit_code == 0 lines = result.stdout.strip().splitlines()