From 9bd154fab718c3827a175925342410eb63449332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Fern=C3=A1ndez=20Cervell?= <153729439+AndresFerCervell@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:32:24 +0200 Subject: [PATCH 01/11] Add shell infrastructure for Herings-Peeters (2001) solver (#967) --- Makefile.am | 4 ++++ doc/references.bib | 10 ++++++++++ setup.py | 3 ++- src/pygambit/gambit.pxd | 4 ++++ src/pygambit/nash.pxi | 4 ++++ src/pygambit/nash.py | 29 +++++++++++++++++++++++++++++ src/solvers/hp/hp.cc | 38 ++++++++++++++++++++++++++++++++++++++ src/solvers/hp/hp.h | 32 ++++++++++++++++++++++++++++++++ 8 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/solvers/hp/hp.cc create mode 100644 src/solvers/hp/hp.h diff --git a/Makefile.am b/Makefile.am index 5ef18af9bd..f2ea3fda3d 100644 --- a/Makefile.am +++ b/Makefile.am @@ -313,6 +313,10 @@ gtracer_SOURCES = \ src/solvers/gtracer/gnm.cc \ src/solvers/gtracer/ipa.cc +hp_SOURCES = \ + src/solvers/hp/hp.h \ + src/solvers/hp/hp.cc + if IS_WIN32 AM_LDFLAGS = -static -static-libgcc -static-libstdc++ endif diff --git a/doc/references.bib b/doc/references.bib index f39c14834c..36ae6110e6 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -38,6 +38,16 @@ @article{GovWil04 category = {articles_equilibria} } +@article{HerPee01, + author = {Herings, P. J.-J. and Peeters, R. J. A. P.}, + title = {A differentiable homotopy to compute {N}ash equilibria of n-person games}, + journal = {Economic Theory}, + volume = {18}, + pages = {159--185}, + year = {2001}, + category = {articles_equilibria} +} + @article{HalPas21, author = {Halpern, J. Y. and Pass, R.}, title = {Sequential equilibrium in games of imperfect recall}, diff --git a/setup.py b/setup.py index 9b47eb5333..945b827147 100644 --- a/setup.py +++ b/setup.py @@ -100,6 +100,7 @@ def run(self) -> None: cppgambit_gtracer = solver_library_config("cppgambit_gtracer", ["gtracer", "ipa", "gnm"]) cppgambit_simpdiv = solver_library_config("cppgambit_simpdiv", ["simpdiv"]) cppgambit_enumpoly = solver_library_config("cppgambit_enumpoly", ["nashsupport", "enumpoly"]) +cppgambit_hp = solver_library_config("cppgambit_hp", ["hp"]) libgambit = setuptools.Extension( @@ -117,7 +118,7 @@ def run(self) -> None: setuptools.setup( cmdclass={"build_py": GambitBuildPy}, libraries=[cppgambit_bimatrix, cppgambit_liap, cppgambit_logit, cppgambit_simpdiv, - cppgambit_gtracer, cppgambit_enumpoly, + cppgambit_gtracer, cppgambit_enumpoly, cppgambit_hp, cppgambit_games, cppgambit_core], ext_modules=Cython.Build.cythonize(libgambit, language_level="3str", diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 099e4aa2a6..6442287340 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -621,6 +621,10 @@ cdef extern from "solvers/logit/logit.h": double getitem "operator[]"(int) except +IndexError +cdef extern from "solvers/hp/hp.h": + stdlist[c_MixedStrategyProfile[double]] HPStrategySolve(c_Game) except +RuntimeError + + cdef extern from "nash.h": stdlist[c_MixedBehaviorProfile[double]] LogitBehaviorSolveWrapper( c_Game, double, double, double diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index 00c74cf3b9..881bd99e15 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -368,3 +368,7 @@ def _logit_behavior_branch(game: Game, p.thisptr = profile_ptr ret.append(p) return ret + + +def _hp_strategy_solve(game: Game) -> list[MixedStrategyProfileDouble]: + return _convert_mspd(HPStrategySolve(game.game)) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 96201df0b6..a233448dfe 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -804,3 +804,32 @@ def logit_solve( equilibria=equilibria, parameters={"first_step": first_step, "max_accel": max_accel}, ) + + +def hp_solve( + game: libgbt.Game, +) -> NashComputationResult: + """Compute Nash equilibria of a game using :cite:p:`HerPee01` + + Returns an approximation to the limiting point on the principal branch of + the homotopy path for the game. + + Parameters + ---------- + game : Game + The game to compute equilibria in. + + Returns + ------- + res : NashComputationResult + The result represented as a ``NashComputationResult`` object. + """ + equilibria = libgbt._hp_strategy_solve(game) + return NashComputationResult( + game=game, + method="hp", + rational=False, + use_strategic=True, + equilibria=equilibria, + parameters={}, + ) diff --git a/src/solvers/hp/hp.cc b/src/solvers/hp/hp.cc new file mode 100644 index 0000000000..15e57b6e33 --- /dev/null +++ b/src/solvers/hp/hp.cc @@ -0,0 +1,38 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/solvers/hp/hp.cc +// Computation of a Nash equilibria using a differentiable homotopy +// +// 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 "gambit.h" +#include "solvers/hp/hp.h" + +namespace Gambit { +std::list> HPStrategySolve(const Game &p_game) +{ + std::list> result; + const StrategySupportProfile support(p_game); + + const MixedStrategyProfile trivial_profile = support.NewMixedStrategyProfile(); + + result.push_back(trivial_profile); + return result; +} +} // namespace Gambit diff --git a/src/solvers/hp/hp.h b/src/solvers/hp/hp.h new file mode 100644 index 0000000000..d51a0dc6c4 --- /dev/null +++ b/src/solvers/hp/hp.h @@ -0,0 +1,32 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (http://www.gambit-project.org) +// +// FILE: src/solvers/hp/hp.h +// Computation of a Nash equilibria using a differentiable homotopy +// +// 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 HP_H +#define HP_H + +#include + +namespace Gambit { +std::list> HPStrategySolve(const Game &p_game); +} // namespace Gambit + +#endif // HP_H From 76fd80983d55f3cc5f3346757f1540b9c56f987a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Fern=C3=A1ndez=20Cervell?= <153729439+AndresFerCervell@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:42:00 +0200 Subject: [PATCH 02/11] Structure of hp method, ComputeInitialPoint and ExtractEquilibrium methods (#973) Implementation of algorithm equation system and termination condition at t=1. Passes several simple tests; more robust test suite to come. --- Makefile.am | 4 +- src/pygambit/gambit.pxd | 4 +- src/pygambit/nash.pxi | 5 +- src/pygambit/nash.py | 10 +- src/solvers/hp/hp.cc | 46 ++++++- src/solvers/hp/hp.h | 3 +- src/solvers/hp/hpsystem.cc | 239 +++++++++++++++++++++++++++++++++++++ src/solvers/hp/hpsystem.h | 70 +++++++++++ tests/test_hp.py | 132 ++++++++++++++++++++ 9 files changed, 497 insertions(+), 16 deletions(-) create mode 100644 src/solvers/hp/hpsystem.cc create mode 100644 src/solvers/hp/hpsystem.h create mode 100644 tests/test_hp.py diff --git a/Makefile.am b/Makefile.am index f2ea3fda3d..fde0f3ca11 100644 --- a/Makefile.am +++ b/Makefile.am @@ -315,7 +315,9 @@ gtracer_SOURCES = \ hp_SOURCES = \ src/solvers/hp/hp.h \ - src/solvers/hp/hp.cc + src/solvers/hp/hp.cc \ + src/solvers/hp/hpsystem.h \ + src/solvers/hp/hpsystem.cc if IS_WIN32 AM_LDFLAGS = -static -static-libgcc -static-libstdc++ diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 6442287340..f147ad8ec1 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -622,7 +622,9 @@ cdef extern from "solvers/logit/logit.h": cdef extern from "solvers/hp/hp.h": - stdlist[c_MixedStrategyProfile[double]] HPStrategySolve(c_Game) except +RuntimeError + stdlist[c_MixedStrategyProfile[double]] HPStrategySolve( + c_MixedStrategyProfile[double] + ) except +RuntimeError cdef extern from "nash.h": diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index 881bd99e15..9dc1019323 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -370,5 +370,6 @@ def _logit_behavior_branch(game: Game, return ret -def _hp_strategy_solve(game: Game) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(HPStrategySolve(game.game)) +def _hp_strategy_solve( + prior: MixedStrategyProfileDouble) -> list[MixedStrategyProfileDouble]: + return _convert_mspd(HPStrategySolve(deref(prior.profile))) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index a233448dfe..e1fdd0b09f 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -807,7 +807,7 @@ def logit_solve( def hp_solve( - game: libgbt.Game, + prior: libgbt.MixedStrategyProfileDouble, ) -> NashComputationResult: """Compute Nash equilibria of a game using :cite:p:`HerPee01` @@ -816,17 +816,17 @@ def hp_solve( Parameters ---------- - game : Game - The game to compute equilibria in. + prior : MixedStrategyProfileDouble + The prior distribution over strategies. Returns ------- res : NashComputationResult The result represented as a ``NashComputationResult`` object. """ - equilibria = libgbt._hp_strategy_solve(game) + equilibria = libgbt._hp_strategy_solve(prior) return NashComputationResult( - game=game, + game=prior.game, method="hp", rational=False, use_strategic=True, diff --git a/src/solvers/hp/hp.cc b/src/solvers/hp/hp.cc index 15e57b6e33..9b71fdd07e 100644 --- a/src/solvers/hp/hp.cc +++ b/src/solvers/hp/hp.cc @@ -23,16 +23,50 @@ #include #include "gambit.h" #include "solvers/hp/hp.h" +#include "solvers/hp/hpsystem.h" +#include "solvers/logit/path.h" namespace Gambit { -std::list> HPStrategySolve(const Game &p_game) +std::list> +HPStrategySolve(const MixedStrategyProfile &p_prior) { - std::list> result; - const StrategySupportProfile support(p_game); - const MixedStrategyProfile trivial_profile = support.NewMixedStrategyProfile(); + std::list> equilibria; - result.push_back(trivial_profile); - return result; + HPEquationSystem system(p_prior); + Vector x = system.ComputeInitialPoint(); + + const PathTracer tracer; + double omega = 1.0; + + auto termination_condition = [](const Vector &point) { return point[1] >= 1.5; }; + auto criterion_function = [](const Vector &point, + const Vector &tangent) -> double { return point[1] - 1.0; }; + + const TracePathResult result = tracer.TracePath( + [&system](const Vector &point, Vector &lhs) { system.GetValue(point, lhs); }, + [&system](const Vector &point, Matrix &jac) { + system.GetJacobian(point, jac); + }, + x, omega, termination_condition, + [&system](const Vector &point) { + std::cout << "[Path Tracer Step] t = " << point[1]; + std::cout << " | Alfas: "; + for (size_t i = 2; i <= 5; ++i) { + std::cout << point[i] << " "; + } + std::cout << "| Mu: " << point[6] << " " << point[7] << std::endl; + + std::cout << "Full point vector in probabilities: "; + Vector prob_vector = system.ExtractEquilibrium(point).GetProbVector(); + for (size_t i = 1; i <= prob_vector.size(); ++i) { + std::cout << prob_vector[i] << " "; + } + std::cout << std::endl; + }, + criterion_function); + + equilibria.push_back(system.ExtractEquilibrium(x)); + return equilibria; } } // namespace Gambit diff --git a/src/solvers/hp/hp.h b/src/solvers/hp/hp.h index d51a0dc6c4..2ed16a4303 100644 --- a/src/solvers/hp/hp.h +++ b/src/solvers/hp/hp.h @@ -26,7 +26,8 @@ #include namespace Gambit { -std::list> HPStrategySolve(const Game &p_game); +std::list> +HPStrategySolve(const MixedStrategyProfile &p_prior); } // namespace Gambit #endif // HP_H diff --git a/src/solvers/hp/hpsystem.cc b/src/solvers/hp/hpsystem.cc new file mode 100644 index 0000000000..1a5898722b --- /dev/null +++ b/src/solvers/hp/hpsystem.cc @@ -0,0 +1,239 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/solvers/hp/hpsystem.cc +// Computation of a Nash equilibria using a differentiable homotopy +// +// 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 "gambit.h" +#include "solvers/hp/hpsystem.h" + +namespace Gambit { +HPEquationSystem::HPEquationSystem(const MixedStrategyProfile &prior) + : m_game(prior.GetGame()), m_prior(prior), + m_current_sigma(prior.GetGame()->NewMixedStrategyProfile(0.0)), + m_star(prior.MixedProfileLength()) +{ + m_payoffs_against_prior.reserve(m_prior.MixedProfileLength()); + for (const auto &player : m_game->GetPlayers()) { + for (const auto &strategy : player->GetStrategies()) { + m_payoffs_against_prior.push_back(m_prior.GetPayoff(strategy)); + } + } +} + +void HPEquationSystem::GetValue(const Vector &point, Vector &lhs) const +{ + const double t = point[1]; + + int temp_alpha_idx = 2; + for (const auto &player : m_game->GetPlayers()) { + for (const auto &strategy : player->GetStrategies()) { + m_current_sigma[strategy] = AlphaToSigma(point[temp_alpha_idx++]); + } + } + + int alpha_idx = 2; + int eq_idx = 1; + int player_idx = 1; + int flat_strategy_idx = 0; + + for (const auto &player : m_game->GetPlayers()) { + + const double mu = point[1 + m_star + player_idx]; + double sum_sigma = 0.0; + + // (a) Best response equations + for (const auto &strategy : player->GetStrategies()) { + const double alpha = point[alpha_idx++]; + const double lambda = AlphaToLambda(alpha); + const double sigma = AlphaToSigma(alpha); + sum_sigma += sigma; + + const double v_i = CalculateDynamicPayoff(flat_strategy_idx++, strategy, m_current_sigma, t); + + lhs[eq_idx++] = v_i + lambda - mu; + } + + // (b) Probability sum equation + lhs[eq_idx++] = sum_sigma - 1.0; + + player_idx++; + } +} + +void HPEquationSystem::GetJacobian(const Vector &point, Matrix &p_jac) const +{ + const double t = point[1]; + + // Compute current sigma from alpha values + int temp_alpha_idx = 2; + for (const auto &player : m_game->GetPlayers()) { + for (const auto &strategy : player->GetStrategies()) { + m_current_sigma[strategy] = AlphaToSigma(point[temp_alpha_idx++]); + } + } + + // Initialize the Jacobian matrix to zero + p_jac = 0.0; + + int eq_idx = 1; + int player_idx = 1; + int flat_s1_idx = 0; + + for (const auto &player1 : m_game->GetPlayers()) { + + // (a) Best response equations + for (const auto &strat1 : player1->GetStrategies()) { + + // Column of t: + const double payoff_vs_sigma = m_current_sigma.GetPayoff(strat1); + const double payoff_vs_prior = m_payoffs_against_prior[flat_s1_idx]; + p_jac(1, eq_idx) = payoff_vs_sigma - payoff_vs_prior; + + // Column of mu_i: Derivative with respect to mu of this player (-1.0) + p_jac(1 + m_star + player_idx, eq_idx) = -1.0; + + // Alpha columns: + int alpha_col = 2; + for (const auto &player2 : m_game->GetPlayers()) { + for (const auto &strat2 : player2->GetStrategies()) { + const double alpha2 = point[alpha_col]; + + if (player1 == player2) { + // Same strategy, use the derivative of lambda with respect to alpha + if (strat1 == strat2) { + p_jac(alpha_col, eq_idx) = AlphaToLambdaDeriv(alpha2); + } + } + else { + // Using chain rule + const double deriv_u = + m_current_sigma.GetPayoffDeriv(player1->GetNumber(), strat1, strat2); + const double deriv_sigma = AlphaToSigmaDeriv(alpha2); + p_jac(alpha_col, eq_idx) = t * deriv_u * deriv_sigma; + } + alpha_col++; + } + } + eq_idx++; + flat_s1_idx++; + } + + // (b) Probability sum equation + // Derivative with respect to 't' and 'mu' is 0 + // Only derivatives with respect to the alphas of this player + int alpha_col = 2; + for (const auto &player2 : m_game->GetPlayers()) { + for (const auto &strat2 : player2->GetStrategies()) { + if (player1 == player2) { + p_jac(alpha_col, eq_idx) = AlphaToSigmaDeriv(point[alpha_col]); + } + alpha_col++; + } + } + eq_idx++; + player_idx++; + } +} + +Vector HPEquationSystem::ComputeInitialPoint() const +{ + const int n_players = m_game->GetPlayers().size(); + const double tol = 1e-9; // Tolerance for floating-point comparisons + + // Dimension: 1 (t) + m_star (total strategies) + n (number of players) + const int vector_size = 1 + m_star + n_players; + Vector start_point(vector_size); + + start_point[1] = 0.0; // t = 0 + + int alpha_idx = 2; + int player_idx = 1; + int flat_strategy_idx = 0; // Index for accessing m_payoffs_against_prior + + for (const auto &player : m_game->GetPlayers()) { + const int temp_idx = flat_strategy_idx; + // Finding mu^i (the maximum payoff for player i against the prior) + double max_payoff = -std::numeric_limits::infinity(); + for (const auto &strategy : player->GetStrategies()) { + const double payoff = m_payoffs_against_prior[flat_strategy_idx++]; + if (payoff > max_payoff) { + max_payoff = payoff; + } + } + + // Store mu^i + start_point[1 + m_star + player_idx] = max_payoff; + + // Compute alpha^i_s for each strategy s of player i + + bool found_br = false; // Flag to check if a best response has been found + int local_s_idx = temp_idx; + for (const auto &strategy : player->GetStrategies()) { + const double lambda = max_payoff - m_payoffs_against_prior[local_s_idx++]; + if (std::abs(lambda) < tol && !found_br) { + start_point[alpha_idx++] = 1.0; // Best response + found_br = true; + } + else if (std::abs(lambda) < tol) { + throw std::runtime_error("Multiple best responses found for player " + + std::to_string(player_idx) + + ". Only one best response is allowed."); + } + else { + // Avoid sqrt of negative numbers + start_point[alpha_idx++] = -std::sqrt(std::max(0.0, lambda)); + } + } + player_idx++; + } + + return start_point; +} + +MixedStrategyProfile +HPEquationSystem::ExtractEquilibrium(const Vector &final_point) const +{ + MixedStrategyProfile ret = m_game->NewMixedStrategyProfile(0.0); + int alpha_idx = 2; // First position is reserved to t + + for (const auto &player : m_game->GetPlayers()) { + for (const auto &strategy : player->GetStrategies()) { + const double alpha_val = final_point[alpha_idx++]; + const double prob = this->AlphaToSigma(alpha_val); + ret[strategy] = prob; + } + } + ret = ret.Normalize(); + + return ret; +} + +// v^i(t, s) +double HPEquationSystem::CalculateDynamicPayoff(int action_index, const GameStrategy &strategy, + const MixedStrategyProfile ¤t_sigma, + double t) const +{ + const double payoff_against_sigma = current_sigma.GetPayoff(strategy); + const double payoff_against_prior = m_payoffs_against_prior[action_index]; + return t * payoff_against_sigma + (1.0 - t) * payoff_against_prior; +} + +} // end namespace Gambit diff --git a/src/solvers/hp/hpsystem.h b/src/solvers/hp/hpsystem.h new file mode 100644 index 0000000000..fd8d2e9cf1 --- /dev/null +++ b/src/solvers/hp/hpsystem.h @@ -0,0 +1,70 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (http://www.gambit-project.org) +// +// FILE: src/solvers/hp/hpsystem.h +// Computation of a Nash equilibria using a differentiable homotopy +// +// 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 HPSYSTEM_H +#define HPSYSTEM_H + +#include + +namespace Gambit { + +class HPEquationSystem { +public: + HPEquationSystem(const MixedStrategyProfile &prior); + + // Evaluates H(t, alpha, mu) = 0 + void GetValue(const Vector &point, Vector &lhs) const; + + void GetJacobian(const Vector &point, Matrix &jac) const; + + // Computes the initial point for the homotopy path tracing (t=0) + Vector ComputeInitialPoint() const; + + // Transforms the final vector into an equilibrium mixed strategy profile + MixedStrategyProfile ExtractEquilibrium(const Vector &final_point) const; + +private: + const Game m_game; + MixedStrategyProfile m_prior; + std::vector m_payoffs_against_prior; + int m_star; + mutable MixedStrategyProfile m_current_sigma; + + // Transforms alpha to sigma and lambda + inline double AlphaToSigma(double alpha) const { return (alpha > 0.0) ? (alpha * alpha) : 0.0; } + inline double AlphaToLambda(double alpha) const { return (alpha < 0.0) ? (alpha * alpha) : 0.0; } + + // d(sigma)/d(alpha) + inline double AlphaToSigmaDeriv(double alpha) const { return (alpha > 0.0) ? 2.0 * alpha : 0.0; } + // d(lambda)/d(alpha) + inline double AlphaToLambdaDeriv(double alpha) const + { + return (alpha < 0.0) ? 2.0 * alpha : 0.0; + } + + // v^i(t, s) + double CalculateDynamicPayoff(int action_index, const GameStrategy &strategy, + const MixedStrategyProfile ¤t_sigma, double t) const; +}; + +} // namespace Gambit +#endif // HPSYSTEM_H diff --git a/tests/test_hp.py b/tests/test_hp.py new file mode 100644 index 0000000000..4b6af3cca6 --- /dev/null +++ b/tests/test_hp.py @@ -0,0 +1,132 @@ +"""Test of calls to the Herings & Peeters (2001) homotopy solver.""" + +import dataclasses +import typing + +import numpy as np +import pytest + +import pygambit as gbt + +TOL = 1e-6 + + +def d(*probs) -> tuple: + """Helper function to let us write d() to be suggestive of + "probability distribution on simplex" ("Delta") + """ + return tuple(probs) + + +@dataclasses.dataclass +class HPSolverTestCase: + """Summarising the data relevant for a test fixture of a call to the HP solver.""" + factory: typing.Callable[[], gbt.MixedStrategyProfileDouble] + expected: list + prob_tol: float = TOL + + +def create_hs_base_game() -> gbt.Game: + """Creates the base 2x2 game used in all examples from Harsanyi & Selten (1988) Section 4.11 + and also featured in Herings & Peeters (2001). + """ + p1_payoffs = np.array([[2, 0], [0, 1]]) + p2_payoffs = np.array([[1, 0], [0, 4]]) + return gbt.Game.from_arrays(p1_payoffs, p2_payoffs, title="HS 1988 Base Game") + + +def create_hp_paper_example() -> gbt.MixedStrategyProfileDouble: + """Creates the example from Herings & Peeters (2001) Figure 1. + Also used in Harsanyi & Selten (1988) Section 4.11. -Second Example.""" + game = create_hs_base_game() + prior = game.mixed_strategy_profile() + p1, p2 = list(game.players) + + prior[list(p1.strategies)[0]] = 0.5 + prior[list(p1.strategies)[1]] = 0.5 + prior[list(p2.strategies)[0]] = 2.0 / 3.0 + prior[list(p2.strategies)[1]] = 1.0 / 3.0 + + return prior + + +def create_hs_example_1() -> gbt.MixedStrategyProfileDouble: + """Harsanyi & Selten (1988) Section 4.11 - First Example.""" + game = create_hs_base_game() + prior = game.mixed_strategy_profile() + p1, p2 = list(game.players) + + prior[list(p1.strategies)[0]] = 1.0 / 3.0 + prior[list(p1.strategies)[1]] = 2.0 / 3.0 + prior[list(p2.strategies)[0]] = 1.0 / 6.0 + prior[list(p2.strategies)[1]] = 5.0 / 6.0 + + return prior + + +def create_t0_degenerate_example() -> gbt.MixedStrategyProfileDouble: + """A prior that causes multiple best responses exactly at t=0.""" + game = create_hs_base_game() + prior = game.mixed_strategy_profile() + p1, p2 = list(game.players) + + prior[list(p1.strategies)[0]] = 2.0 / 3.0 + prior[list(p1.strategies)[1]] = 1.0 / 3.0 + prior[list(p2.strategies)[0]] = 1.0 / 3.0 + prior[list(p2.strategies)[1]] = 2.0 / 3.0 + + return prior + + +HP_CASES = [ + pytest.param( + HPSolverTestCase( + factory=create_hp_paper_example, + expected=[d(0.0, 1.0), d(0.0, 1.0)], + ), + id="test_hp_herings_peeters_example", + ), + pytest.param( + HPSolverTestCase( + factory=create_hs_example_1, + expected=[d(0.0, 1.0), d(0.0, 1.0)], + ), + id="test_hp_hs_example_1", + ), +] + + +@pytest.mark.nash +@pytest.mark.parametrize("test_case", HP_CASES) +def test_hp_strategy_solver(test_case: HPSolverTestCase, subtests) -> None: + """Test calls of the HP solver with starting priors. + + Subtests: + - Number of equilibria found is exactly 1. + - Equilibrium profile matches the expected theoretical result. + """ + prior = test_case.factory() + game = prior.game + + result = gbt.nash.hp_solve(prior=prior) + + with subtests.test("number of equilibria found"): + # The HP method uniquely selects exactly 1 equilibrium. + assert len(result.equilibria) == 1 + + eq = result.equilibria[0] + expected = game.mixed_strategy_profile(rational=False, data=test_case.expected) + + with subtests.test("strategy_profile matches expected"): + for player in game.players: + for strategy in player.strategies: + assert abs(eq[strategy] - expected[strategy]) <= test_case.prob_tol + + +@pytest.mark.nash +def test_hp_degenerate_t0_prior_raises_error() -> None: + """Test that the HP solver correctly identifies when given a degenerate prior.""" + prior = create_t0_degenerate_example() + with pytest.raises(RuntimeError, match="Multiple best responses found for player 1. " + "Only one best response is allowed."): + gbt.nash.hp_solve(prior=prior) From 72c965093dfc553ebe57cbae0801a337327f19e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Fern=C3=A1ndez=20Cervell?= <153729439+AndresFerCervell@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:44:01 +0200 Subject: [PATCH 03/11] Refactoring HPsystem (#1019) Refactors the HP system of equations to the class-based equation pattern. --- src/solvers/hp/hpsystem.cc | 279 +++++++++++++++++++++++-------------- src/solvers/hp/hpsystem.h | 22 +-- 2 files changed, 181 insertions(+), 120 deletions(-) diff --git a/src/solvers/hp/hpsystem.cc b/src/solvers/hp/hpsystem.cc index 1a5898722b..3f590b0b87 100644 --- a/src/solvers/hp/hpsystem.cc +++ b/src/solvers/hp/hpsystem.cc @@ -25,131 +25,202 @@ #include "solvers/hp/hpsystem.h" namespace Gambit { -HPEquationSystem::HPEquationSystem(const MixedStrategyProfile &prior) - : m_game(prior.GetGame()), m_prior(prior), - m_current_sigma(prior.GetGame()->NewMixedStrategyProfile(0.0)), - m_star(prior.MixedProfileLength()) -{ - m_payoffs_against_prior.reserve(m_prior.MixedProfileLength()); - for (const auto &player : m_game->GetPlayers()) { - for (const auto &strategy : player->GetStrategies()) { - m_payoffs_against_prior.push_back(m_prior.GetPayoff(strategy)); + +class HPEquation { +public: + virtual ~HPEquation() = default; + + virtual double Value(const Vector &point, + const MixedStrategyProfile ¤t_sigma, + const std::vector &payoffs_against_prior) const = 0; + + virtual void Gradient(const Vector &point, + const MixedStrategyProfile ¤t_sigma, + const std::vector &payoffs_against_prior, + Vector &gradient) const = 0; +}; + +namespace { + +// Transforms alpha to sigma and lambda +inline double AlphaToSigma(double alpha) { return (alpha > 0.0) ? (alpha * alpha) : 0.0; } +inline double AlphaToLambda(double alpha) { return (alpha < 0.0) ? (alpha * alpha) : 0.0; } + +// d(sigma)/d(alpha) +inline double AlphaToSigmaDeriv(double alpha) { return (alpha > 0.0) ? 2.0 * alpha : 0.0; } +// d(lambda)/d(alpha) +inline double AlphaToLambdaDeriv(double alpha) { return (alpha < 0.0) ? 2.0 * alpha : 0.0; } + +// Eq (a): Best Response Equation +class BestResponseEquation final : public HPEquation { + GameStrategy m_strategy; + int m_alpha_idx; + int m_mu_idx; + int m_flat_s_idx; + +public: + BestResponseEquation(const GameStrategy &strat, int alpha_idx, int mu_idx, int flat_s_idx) + : m_strategy(strat), m_alpha_idx(alpha_idx), m_mu_idx(mu_idx), m_flat_s_idx(flat_s_idx) + { + } + + ~BestResponseEquation() override = default; + + double Value(const Vector &point, const MixedStrategyProfile ¤t_sigma, + const std::vector &payoffs_against_prior) const override + { + const double t = point[1]; + const double alpha = point[m_alpha_idx]; + const double lambda = AlphaToLambda(alpha); + const double mu = point[m_mu_idx]; + + // Calculate Dynamic Payoff + const double payoff_vs_sigma = current_sigma.GetPayoff(m_strategy); + const double payoff_vs_prior = payoffs_against_prior[m_flat_s_idx]; + + // v^i(t, s) + const double v_i = t * payoff_vs_sigma + (1.0 - t) * payoff_vs_prior; + + return v_i + lambda - mu; + } + + void Gradient(const Vector &point, const MixedStrategyProfile ¤t_sigma, + const std::vector &payoffs_against_prior, + Vector &gradient) const override + { + gradient = 0.0; + const double t = point[1]; + const GamePlayer my_player = m_strategy->GetPlayer(); + const Game game = m_strategy->GetGame(); + + // Derivative wrt t: + const double payoff_vs_sigma = current_sigma.GetPayoff(m_strategy); + const double payoff_vs_prior = payoffs_against_prior[m_flat_s_idx]; + gradient[1] = payoff_vs_sigma - payoff_vs_prior; + + // Derivative wrt mu_i: + gradient[m_mu_idx] = -1.0; + + // Derivatives wrt all alphas: + int alpha_col = 2; + for (const auto &player2 : game->GetPlayers()) { + for (const auto &strat2 : player2->GetStrategies()) { + const double alpha2 = point[alpha_col]; + + if (my_player == player2) { + if (m_strategy == strat2) { + gradient[alpha_col] = AlphaToLambdaDeriv(alpha2); + } + } + else { + // Chain rule for opposing players' strategies + const double deriv_u = + current_sigma.GetPayoffDeriv(my_player->GetNumber(), m_strategy, strat2); + const double deriv_sigma = AlphaToSigmaDeriv(alpha2); + gradient[alpha_col] = t * deriv_u * deriv_sigma; + } + alpha_col++; + } } } -} +}; -void HPEquationSystem::GetValue(const Vector &point, Vector &lhs) const -{ - const double t = point[1]; +// Eq (b): Probability Sum Equation +class ProbabilitySumEquation final : public HPEquation { + int m_first_alpha_idx; + int m_last_alpha_idx; - int temp_alpha_idx = 2; +public: + ProbabilitySumEquation(int first_idx, int last_idx) + : m_first_alpha_idx(first_idx), m_last_alpha_idx(last_idx) + { + } + + ~ProbabilitySumEquation() override = default; + + double Value(const Vector &point, const MixedStrategyProfile ¤t_sigma, + const std::vector &payoffs_against_prior) const override + { + double sum_sigma = 0.0; + for (int i = m_first_alpha_idx; i < m_last_alpha_idx; ++i) { + sum_sigma += AlphaToSigma(point[i]); + } + return sum_sigma - 1.0; + } + + void Gradient(const Vector &point, const MixedStrategyProfile ¤t_sigma, + const std::vector &payoffs_against_prior, + Vector &gradient) const override + { + gradient = 0.0; + // Only non-zero derivatives are those with respect to the player's own alphas + for (int i = m_first_alpha_idx; i < m_last_alpha_idx; ++i) { + gradient[i] = AlphaToSigmaDeriv(point[i]); + } + } +}; + +} // end namespace + +HPEquationSystem::HPEquationSystem(const MixedStrategyProfile &prior) + : m_game(prior.GetGame()), m_prior(prior), m_star(prior.MixedProfileLength()), + m_current_sigma(prior.GetGame()->NewMixedStrategyProfile(0.0)) +{ + m_payoffs_against_prior.reserve(m_prior.MixedProfileLength()); for (const auto &player : m_game->GetPlayers()) { for (const auto &strategy : player->GetStrategies()) { - m_current_sigma[strategy] = AlphaToSigma(point[temp_alpha_idx++]); + m_payoffs_against_prior.push_back(m_prior.GetPayoff(strategy)); } } + // Pre-allocate space for all equations: m_star (Best Responses) + n (Prob Sums) + m_equations.reserve(m_star + m_game->GetPlayers().size()); int alpha_idx = 2; - int eq_idx = 1; int player_idx = 1; int flat_strategy_idx = 0; for (const auto &player : m_game->GetPlayers()) { + const int first_alpha = alpha_idx; + const int mu_idx = 1 + m_star + player_idx; - const double mu = point[1 + m_star + player_idx]; - double sum_sigma = 0.0; - - // (a) Best response equations + // Instantiate Best Response Equations for (const auto &strategy : player->GetStrategies()) { - const double alpha = point[alpha_idx++]; - const double lambda = AlphaToLambda(alpha); - const double sigma = AlphaToSigma(alpha); - sum_sigma += sigma; - - const double v_i = CalculateDynamicPayoff(flat_strategy_idx++, strategy, m_current_sigma, t); - - lhs[eq_idx++] = v_i + lambda - mu; + m_equations.push_back( + std::make_shared(strategy, alpha_idx, mu_idx, flat_strategy_idx)); + alpha_idx++; + flat_strategy_idx++; } - // (b) Probability sum equation - lhs[eq_idx++] = sum_sigma - 1.0; + // Instantiate Probability Sum Equations + m_equations.push_back(std::make_shared(first_alpha, alpha_idx)); player_idx++; } } -void HPEquationSystem::GetJacobian(const Vector &point, Matrix &p_jac) const +void HPEquationSystem::GetValue(const Vector &point, Vector &lhs) const { - const double t = point[1]; + // Update internal mutable state + UpdateSigma(point); - // Compute current sigma from alpha values - int temp_alpha_idx = 2; - for (const auto &player : m_game->GetPlayers()) { - for (const auto &strategy : player->GetStrategies()) { - m_current_sigma[strategy] = AlphaToSigma(point[temp_alpha_idx++]); - } + // Evaluate all equations + for (size_t i = 1; i <= m_equations.size(); ++i) { + lhs[i] = m_equations[i - 1]->Value(point, m_current_sigma, m_payoffs_against_prior); } +} - // Initialize the Jacobian matrix to zero - p_jac = 0.0; - - int eq_idx = 1; - int player_idx = 1; - int flat_s1_idx = 0; - - for (const auto &player1 : m_game->GetPlayers()) { - - // (a) Best response equations - for (const auto &strat1 : player1->GetStrategies()) { - - // Column of t: - const double payoff_vs_sigma = m_current_sigma.GetPayoff(strat1); - const double payoff_vs_prior = m_payoffs_against_prior[flat_s1_idx]; - p_jac(1, eq_idx) = payoff_vs_sigma - payoff_vs_prior; - - // Column of mu_i: Derivative with respect to mu of this player (-1.0) - p_jac(1 + m_star + player_idx, eq_idx) = -1.0; - - // Alpha columns: - int alpha_col = 2; - for (const auto &player2 : m_game->GetPlayers()) { - for (const auto &strat2 : player2->GetStrategies()) { - const double alpha2 = point[alpha_col]; +void HPEquationSystem::GetJacobian(const Vector &point, Matrix &p_jac) const +{ + // Update internal mutable state + UpdateSigma(point); - if (player1 == player2) { - // Same strategy, use the derivative of lambda with respect to alpha - if (strat1 == strat2) { - p_jac(alpha_col, eq_idx) = AlphaToLambdaDeriv(alpha2); - } - } - else { - // Using chain rule - const double deriv_u = - m_current_sigma.GetPayoffDeriv(player1->GetNumber(), strat1, strat2); - const double deriv_sigma = AlphaToSigmaDeriv(alpha2); - p_jac(alpha_col, eq_idx) = t * deriv_u * deriv_sigma; - } - alpha_col++; - } - } - eq_idx++; - flat_s1_idx++; - } + p_jac = 0.0; + Vector column(point.size()); // Temp vector matching Jacobian column size - // (b) Probability sum equation - // Derivative with respect to 't' and 'mu' is 0 - // Only derivatives with respect to the alphas of this player - int alpha_col = 2; - for (const auto &player2 : m_game->GetPlayers()) { - for (const auto &strat2 : player2->GetStrategies()) { - if (player1 == player2) { - p_jac(alpha_col, eq_idx) = AlphaToSigmaDeriv(point[alpha_col]); - } - alpha_col++; - } - } - eq_idx++; - player_idx++; + // Compute the Jacobian + for (size_t i = 1; i <= m_equations.size(); ++i) { + m_equations[i - 1]->Gradient(point, m_current_sigma, m_payoffs_against_prior, column); + p_jac.SetColumn(i, column); } } @@ -217,7 +288,7 @@ HPEquationSystem::ExtractEquilibrium(const Vector &final_point) const for (const auto &player : m_game->GetPlayers()) { for (const auto &strategy : player->GetStrategies()) { const double alpha_val = final_point[alpha_idx++]; - const double prob = this->AlphaToSigma(alpha_val); + const double prob = AlphaToSigma(alpha_val); ret[strategy] = prob; } } @@ -226,14 +297,14 @@ HPEquationSystem::ExtractEquilibrium(const Vector &final_point) const return ret; } -// v^i(t, s) -double HPEquationSystem::CalculateDynamicPayoff(int action_index, const GameStrategy &strategy, - const MixedStrategyProfile ¤t_sigma, - double t) const +void HPEquationSystem::UpdateSigma(const Vector &point) const { - const double payoff_against_sigma = current_sigma.GetPayoff(strategy); - const double payoff_against_prior = m_payoffs_against_prior[action_index]; - return t * payoff_against_sigma + (1.0 - t) * payoff_against_prior; + int temp_alpha_idx = 2; + for (const auto &player : m_game->GetPlayers()) { + for (const auto &strategy : player->GetStrategies()) { + m_current_sigma[strategy] = AlphaToSigma(point[temp_alpha_idx++]); + } + } } } // end namespace Gambit diff --git a/src/solvers/hp/hpsystem.h b/src/solvers/hp/hpsystem.h index fd8d2e9cf1..3f3db28ba9 100644 --- a/src/solvers/hp/hpsystem.h +++ b/src/solvers/hp/hpsystem.h @@ -27,9 +27,12 @@ namespace Gambit { +class HPEquation; + class HPEquationSystem { public: - HPEquationSystem(const MixedStrategyProfile &prior); + explicit HPEquationSystem(const MixedStrategyProfile &prior); + ~HPEquationSystem() = default; // Evaluates H(t, alpha, mu) = 0 void GetValue(const Vector &point, Vector &lhs) const; @@ -48,22 +51,9 @@ class HPEquationSystem { std::vector m_payoffs_against_prior; int m_star; mutable MixedStrategyProfile m_current_sigma; + std::vector> m_equations; - // Transforms alpha to sigma and lambda - inline double AlphaToSigma(double alpha) const { return (alpha > 0.0) ? (alpha * alpha) : 0.0; } - inline double AlphaToLambda(double alpha) const { return (alpha < 0.0) ? (alpha * alpha) : 0.0; } - - // d(sigma)/d(alpha) - inline double AlphaToSigmaDeriv(double alpha) const { return (alpha > 0.0) ? 2.0 * alpha : 0.0; } - // d(lambda)/d(alpha) - inline double AlphaToLambdaDeriv(double alpha) const - { - return (alpha < 0.0) ? 2.0 * alpha : 0.0; - } - - // v^i(t, s) - double CalculateDynamicPayoff(int action_index, const GameStrategy &strategy, - const MixedStrategyProfile ¤t_sigma, double t) const; + void UpdateSigma(const Vector &point) const; }; } // namespace Gambit From a7d358c2e38e7fbf507ff814613335dea948da61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Fern=C3=A1ndez=20Cervell?= <153729439+AndresFerCervell@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:44:05 +0200 Subject: [PATCH 04/11] Adding a controlled direction in path.cc (#1030) This updates `path.cc ` so that the tracer always follows the curve in the direction chosen by the programmer. It has been proven useful in the new `HP`algorithm to avoid a random direction in `t=0`. `logit` has also been affected by the modifications: the headers have been updated. The mechanism implemented is the following: -In `path.h` an enum type named **`TraceDirection`** has been declared. It can be `Positive` associated to the number 1, and `Negative`, (-1). Positive means that the tracer will move forward; negative backwards. -To control whether the tracer is headed into the right direction in the first iteration, it is required to know which is the index of the variable that we are following (`lambda`in `logit`, `t` in `HP`). Another parameter has been added to `TracePath` named **`tracking_index`** that stores the position of that variable in the vector `x`. Note that in `HP` it is the first position and in `logit` is the last one. The check that we are heading the right way is as discussed: if the first step goes as expected (equivalent to the tangent being positive), the internal omega does not change. Otherwise, it is multiplied by -1. Tracing returns with an error result if the tangent in the first step is 0 (up to numerical tolerance) --- src/pygambit/nash.h | 33 +++++++++++++++++------------ src/solvers/hp/hp.cc | 6 +++--- src/solvers/logit/efglogit.cc | 19 +++++++++-------- src/solvers/logit/logit.h | 26 ++++++++++++----------- src/solvers/logit/nfglogit.cc | 19 +++++++++-------- src/solvers/logit/path.cc | 40 ++++++++++++++++++++++++++--------- src/solvers/logit/path.h | 4 +++- src/tools/logit/logit.cc | 17 +++++++++------ 8 files changed, 99 insertions(+), 65 deletions(-) diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 4643e4e381..0707488efb 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -22,6 +22,7 @@ #include "gambit.h" #include "solvers/logit/logit.h" +#include "solvers/logit/path.h" using namespace std; using namespace Gambit; @@ -32,8 +33,8 @@ std::list> LogitBehaviorSolveWrapper(const Game &p_ double p_maxAccel) { std::list> ret; - ret.push_back(LogitBehaviorSolve(LogitQREMixedBehaviorProfile(p_game), p_regret, 1.0, - p_firstStep, p_maxAccel) + ret.push_back(LogitBehaviorSolve(LogitQREMixedBehaviorProfile(p_game), p_regret, + PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel) .back() .GetProfile()); return ret; @@ -43,16 +44,17 @@ inline std::list LogitBehaviorPrincipalBranchWrapper(const Game &p_game, double p_regret, double p_firstStep, double p_maxAccel) { - return LogitBehaviorSolve(LogitQREMixedBehaviorProfile(p_game), p_regret, 1.0, p_firstStep, - p_maxAccel); + return LogitBehaviorSolve(LogitQREMixedBehaviorProfile(p_game), p_regret, + PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel); } std::shared_ptr LogitBehaviorEstimateWrapper(std::shared_ptr> p_frequencies, bool p_stopAtLocal, double p_firstStep, double p_maxAccel) { - return make_shared(LogitBehaviorEstimate( - *p_frequencies, 1000000.0, 1.0, p_stopAtLocal, p_firstStep, p_maxAccel)); + return make_shared( + LogitBehaviorEstimate(*p_frequencies, 1000000.0, PathTracer::TraceDirection::Positive, + p_stopAtLocal, p_firstStep, p_maxAccel)); } std::list> @@ -61,7 +63,8 @@ LogitBehaviorAtLambdaWrapper(const Game &p_game, const std::list &p_targ { 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, PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel)) { ret.push_back(std::make_shared(qre)); } return ret; @@ -73,8 +76,8 @@ std::list> LogitStrategySolveWrapper(const Game &p_ double p_maxAccel) { std::list> ret; - ret.push_back(LogitStrategySolve(LogitQREMixedStrategyProfile(p_game), p_regret, 1.0, - p_firstStep, p_maxAccel) + ret.push_back(LogitStrategySolve(LogitQREMixedStrategyProfile(p_game), p_regret, + PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel) .back() .GetProfile()); return ret; @@ -84,8 +87,8 @@ inline std::list LogitStrategyPrincipalBranchWrapper(const Game &p_game, double p_regret, double p_firstStep, double p_maxAccel) { - return LogitStrategySolve(LogitQREMixedStrategyProfile(p_game), p_regret, 1.0, p_firstStep, - p_maxAccel); + return LogitStrategySolve(LogitQREMixedStrategyProfile(p_game), p_regret, + PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel); } std::list> @@ -94,7 +97,8 @@ LogitStrategyAtLambdaWrapper(const Game &p_game, const std::list &p_targ { 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, PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel)) { ret.push_back(std::make_shared(qre)); } return ret; @@ -104,6 +108,7 @@ std::shared_ptr LogitStrategyEstimateWrapper(std::shared_ptr> p_frequencies, bool p_stopAtLocal, double p_firstStep, double p_maxAccel) { - return make_shared(LogitStrategyEstimate( - *p_frequencies, 1000000.0, 1.0, p_stopAtLocal, p_firstStep, p_maxAccel)); + return make_shared( + LogitStrategyEstimate(*p_frequencies, 1000000.0, PathTracer::TraceDirection::Positive, + p_stopAtLocal, p_firstStep, p_maxAccel)); } diff --git a/src/solvers/hp/hp.cc b/src/solvers/hp/hp.cc index 9b71fdd07e..1e75d8dddb 100644 --- a/src/solvers/hp/hp.cc +++ b/src/solvers/hp/hp.cc @@ -37,8 +37,8 @@ HPStrategySolve(const MixedStrategyProfile &p_prior) Vector x = system.ComputeInitialPoint(); const PathTracer tracer; - double omega = 1.0; - + const PathTracer::TraceDirection direction = PathTracer::TraceDirection::Positive; + const size_t tracking_index = 1; // Track the first variable (t) for orientation auto termination_condition = [](const Vector &point) { return point[1] >= 1.5; }; auto criterion_function = [](const Vector &point, const Vector &tangent) -> double { return point[1] - 1.0; }; @@ -48,7 +48,7 @@ HPStrategySolve(const MixedStrategyProfile &p_prior) [&system](const Vector &point, Matrix &jac) { system.GetJacobian(point, jac); }, - x, omega, termination_condition, + x, direction, tracking_index, termination_condition, [&system](const Vector &point) { std::cout << "[Path Tracer Step] t = " << point[1]; std::cout << " | Alfas: "; diff --git a/src/solvers/logit/efglogit.cc b/src/solvers/logit/efglogit.cc index cae2f769d6..280bafc89f 100644 --- a/src/solvers/logit/efglogit.cc +++ b/src/solvers/logit/efglogit.cc @@ -309,8 +309,8 @@ void EstimatorCallbackFunction::EvaluatePoint(const Vector &p_point) namespace Gambit { std::list -LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, double p_omega, - double p_firstStep, double p_maxAccel, +LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, + PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, MixedBehaviorObserverFunctionType p_observer) { if (p_start.size() == 0) { @@ -335,7 +335,7 @@ LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, [&system](const Vector &p_point, Matrix &p_jac) { system.GetJacobian(p_point, p_jac); }, - x, p_omega, + x, p_direction, x.size(), [game, p_regret](const Vector &p_point) { return RegretTerminationFunction(game, p_point, p_regret); }, @@ -345,9 +345,9 @@ LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, std::list LogitBehaviorSolveLambda(const LogitQREMixedBehaviorProfile &p_start, - const std::list &p_targetLambda, double p_omega, - double p_firstStep, double p_maxAccel, - MixedBehaviorObserverFunctionType p_observer) + const std::list &p_targetLambda, + PathTracer::TraceDirection p_direction, double p_firstStep, + double p_maxAccel, MixedBehaviorObserverFunctionType p_observer) { if (p_start.size() == 0) { return {p_start}; @@ -369,7 +369,7 @@ LogitBehaviorSolveLambda(const LogitQREMixedBehaviorProfile &p_start, [&system](const Vector &p_point, Matrix &p_jac) { system.GetJacobian(p_point, p_jac); }, - x, p_omega, LambdaPositiveTerminationFunction, + x, p_direction, x.size(), LambdaPositiveTerminationFunction, [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, [lam](const Vector &x, const Vector &) -> double { return x.back() - lam; @@ -381,7 +381,8 @@ LogitBehaviorSolveLambda(const LogitQREMixedBehaviorProfile &p_start, LogitQREMixedBehaviorProfile LogitBehaviorEstimate(const MixedBehaviorProfile &p_frequencies, double p_maxLambda, - double p_omega, double p_stopAtLocal, double p_firstStep, double p_maxAccel, + PathTracer::TraceDirection p_direction, double p_stopAtLocal, + double p_firstStep, double p_maxAccel, MixedBehaviorObserverFunctionType p_observer) { const LogitQREMixedBehaviorProfile start(p_frequencies.GetGame()); @@ -405,7 +406,7 @@ LogitBehaviorEstimate(const MixedBehaviorProfile &p_frequencies, double [&system](const Vector &p_point, Matrix &p_jac) { system.GetJacobian(p_point, p_jac); }, - x, p_omega, + x, p_direction, x.size(), [p_maxLambda](const Vector &p_point) { return LambdaRangeTerminationFunction(p_point, 0, p_maxLambda); }, diff --git a/src/solvers/logit/logit.h b/src/solvers/logit/logit.h index 8d619d44e0..a2d19fcd45 100644 --- a/src/solvers/logit/logit.h +++ b/src/solvers/logit/logit.h @@ -24,6 +24,7 @@ #define SOLVERS_LOGIT_H #include +#include "solvers/logit/path.h" namespace Gambit { @@ -85,18 +86,19 @@ using MixedStrategyObserverFunctionType = inline void NullMixedStrategyObserver(const LogitQREMixedStrategyProfile &) {} std::list LogitStrategySolve( - const LogitQREMixedStrategyProfile &p_start, double p_regret, double p_omega, - double p_firstStep, double p_maxAccel, + const LogitQREMixedStrategyProfile &p_start, double p_regret, + PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, const MixedStrategyObserverFunctionType &p_observer = NullMixedStrategyObserver); std::list LogitStrategySolveLambda( const LogitQREMixedStrategyProfile &p_start, const std::list &p_targetLambda, - double p_omega, double p_firstStep, double p_maxAccel, + PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, const MixedStrategyObserverFunctionType &p_observer = NullMixedStrategyObserver); LogitQREMixedStrategyProfile LogitStrategyEstimate(const MixedStrategyProfile &p_frequencies, double p_maxLambda, - double p_omega, double p_stopAtLocal, double p_firstStep, double p_maxAccel, + PathTracer::TraceDirection p_direction, double p_stopAtLocal, + double p_firstStep, double p_maxAccel, MixedStrategyObserverFunctionType p_observer = NullMixedStrategyObserver); using LogitQREMixedBehaviorProfile = LogitQRE>; @@ -107,19 +109,19 @@ using MixedBehaviorObserverFunctionType = inline void NullMixedBehaviorObserver(const LogitQREMixedBehaviorProfile &) {} std::list -LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, double p_omega, - double p_firstStep, double p_maxAccel, +LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, + PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, MixedBehaviorObserverFunctionType p_observer = NullMixedBehaviorObserver); -std::list -LogitBehaviorSolveLambda(const LogitQREMixedBehaviorProfile &p_start, - const std::list &p_targetLambda, double p_omega, - double p_firstStep, double p_maxAccel, - MixedBehaviorObserverFunctionType p_observer = NullMixedBehaviorObserver); +std::list LogitBehaviorSolveLambda( + const LogitQREMixedBehaviorProfile &p_start, const std::list &p_targetLambda, + PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, + MixedBehaviorObserverFunctionType p_observer = NullMixedBehaviorObserver); LogitQREMixedBehaviorProfile LogitBehaviorEstimate(const MixedBehaviorProfile &p_frequencies, double p_maxLambda, - double p_omega, double p_stopAtLocal, double p_firstStep, double p_maxAccel, + PathTracer::TraceDirection p_direction, double p_stopAtLocal, + double p_firstStep, double p_maxAccel, MixedBehaviorObserverFunctionType p_observer = NullMixedBehaviorObserver); } // namespace Gambit diff --git a/src/solvers/logit/nfglogit.cc b/src/solvers/logit/nfglogit.cc index e8e9f81679..224e9566de 100644 --- a/src/solvers/logit/nfglogit.cc +++ b/src/solvers/logit/nfglogit.cc @@ -347,8 +347,8 @@ void EstimatorCallbackFunction::EvaluatePoint(const Vector &p_point) } // namespace std::list -LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, double p_omega, - double p_firstStep, double p_maxAccel, +LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, + PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, const MixedStrategyObserverFunctionType &p_observer) { if (p_start.size() == 0) { @@ -374,7 +374,7 @@ LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, [&system](const Vector &p_point, Matrix &p_jac) { system.GetJacobian(p_point, p_jac); }, - x, p_omega, + x, p_direction, x.size(), [p_start, p_regret](const Vector &p_point) { return RegretTerminationFunction(p_start.GetGame(), p_point, p_regret); }, @@ -384,9 +384,9 @@ LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, std::list LogitStrategySolveLambda(const LogitQREMixedStrategyProfile &p_start, - const std::list &p_targetLambda, double p_omega, - double p_firstStep, double p_maxAccel, - const MixedStrategyObserverFunctionType &p_observer) + const std::list &p_targetLambda, + PathTracer::TraceDirection p_direction, double p_firstStep, + double p_maxAccel, const MixedStrategyObserverFunctionType &p_observer) { if (p_start.size() == 0) { return {p_start}; @@ -408,7 +408,7 @@ LogitStrategySolveLambda(const LogitQREMixedStrategyProfile &p_start, [&system](const Vector &p_point, Matrix &p_jac) { system.GetJacobian(p_point, p_jac); }, - x, p_omega, LambdaPositiveTerminationFunction, + x, p_direction, x.size(), LambdaPositiveTerminationFunction, [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, [lam](const Vector &x, const Vector &) -> double { return x.back() - lam; @@ -420,7 +420,8 @@ LogitStrategySolveLambda(const LogitQREMixedStrategyProfile &p_start, LogitQREMixedStrategyProfile LogitStrategyEstimate(const MixedStrategyProfile &p_frequencies, double p_maxLambda, - double p_omega, double p_stopAtLocal, double p_firstStep, double p_maxAccel, + PathTracer::TraceDirection p_direction, double p_stopAtLocal, + double p_firstStep, double p_maxAccel, MixedStrategyObserverFunctionType p_observer) { const LogitQREMixedStrategyProfile start(p_frequencies.GetGame()); @@ -444,7 +445,7 @@ LogitStrategyEstimate(const MixedStrategyProfile &p_frequencies, double [&system](const Vector &p_point, Matrix &p_jac) { system.GetJacobian(p_point, p_jac); }, - x, p_omega, + x, p_direction, x.size(), [p_maxLambda](const Vector &p_point) { return LambdaRangeTerminationFunction(p_point, 0, p_maxLambda); }, diff --git a/src/solvers/logit/path.cc b/src/solvers/logit/path.cc index 267d8f9148..d881561668 100644 --- a/src/solvers/logit/path.cc +++ b/src/solvers/logit/path.cc @@ -38,16 +38,16 @@ inline double sqr(double x) { return x * x; } void Givens(Matrix &b, Matrix &q, double &c1, double &c2, int l1, int l2, int l3) { - if (fabs(c1) + fabs(c2) == 0.0) { + if (std::abs(c1) + std::abs(c2) == 0.0) { return; } double sn; - if (fabs(c2) >= fabs(c1)) { - sn = std::sqrt(1.0 + sqr(c1 / c2)) * fabs(c2); + if (std::abs(c2) >= std::abs(c1)) { + sn = std::sqrt(1.0 + sqr(c1 / c2)) * std::abs(c2); } else { - sn = std::sqrt(1.0 + sqr(c2 / c1)) * fabs(c1); + sn = std::sqrt(1.0 + sqr(c2 / c1)) * std::abs(c1); } const double s1 = c1 / sn; const double s2 = c2 / sn; @@ -128,8 +128,9 @@ void NewtonStep(Matrix &q, Matrix &b, Vector &u, Vector< TracePathResult PathTracer::TracePath(std::function &, Vector &)> p_function, std::function &, Matrix &)> p_jacobian, - Vector &x, double &p_omega, TerminationFunctionType p_terminate, - CallbackFunctionType p_callback, CriterionFunctionType p_criterion, + Vector &x, TraceDirection p_direction, size_t p_trackingIndex, + TerminationFunctionType p_terminate, CallbackFunctionType p_callback, + CriterionFunctionType p_criterion, CriterionBracketFunctionType p_criterionBracket) const { const double c_tol = 1.0e-4; // tolerance for corrector iteration @@ -145,6 +146,7 @@ PathTracer::TracePath(std::function &, Vector const double c_pert = 0.0000001; // The size of perturbation to apply to avoid bifurcation traps double pert = 0.0; // The current version of the perturbation being applied double pert_countdown = 0.0; // How much longer (in arclength) to apply perturbation + const double c_orientTol = 1.0e-8; // tolerance for detecting change in orientation Vector u(x.size()); // t is current tangent at x; newT is tangent at u, which is the next point. @@ -157,17 +159,35 @@ PathTracer::TracePath(std::function &, Vector QRDecomp(b, q); q.GetRow(q.NumRows(), t); p_callback(x); + bool first_step = true; + double omega = (p_direction == TraceDirection::Positive) ? 1.0 : -1.0; + + if (p_trackingIndex > x.size() || p_trackingIndex < 1) { + return {x, false, "Tracking index exceeds dimension of point vector."}; + } while (!p_terminate(x)) { bool accept = true; - if (fabs(h) <= c_hmin) { + if (std::abs(h) <= c_hmin) { return {x, false, "Stepsize fell below minimum threshold."}; } + if (first_step) { + if (std::abs(t[p_trackingIndex]) <= c_orientTol) { + return {x, false, "Initial tangent vector is orthogonal to path-following direction."}; + } + // Ensure that the tangent is oriented in the same direction as + // the path-following direction. + else if (t[p_trackingIndex] < -c_orientTol) { + omega *= -1.0; + } + first_step = false; + } + // Predictor step for (size_t k = 1; k <= x.size(); k++) { - u[k] = x[k] + h * p_omega * t[k]; + u[k] = x[k] + h * omega * t[k]; } double decel = 1.0 / m_maxDecel; // initialize deceleration factor @@ -226,7 +246,7 @@ PathTracer::TracePath(std::function &, Vector if (!accept) { h /= m_maxDecel; // PC not accepted; change stepsize and retry - if (fabs(h) <= c_hmin) { + if (std::abs(h) <= c_hmin) { return {x, false, "Stepsize fell below minimum threshold."}; } continue; @@ -251,7 +271,7 @@ PathTracer::TracePath(std::function &, Vector } else { // Standard steplength adaptation - h = fabs(h / decel); + h = std::abs(h / decel); } // PC step was successful; update and iterate diff --git a/src/solvers/logit/path.h b/src/solvers/logit/path.h index 655194e42a..8193c5764d 100644 --- a/src/solvers/logit/path.h +++ b/src/solvers/logit/path.h @@ -71,6 +71,7 @@ struct TracePathResult { // class PathTracer { public: + enum class TraceDirection { Positive = 1, Negative = -1 }; PathTracer() = default; virtual ~PathTracer() = default; @@ -83,7 +84,8 @@ class PathTracer { TracePathResult TracePath(std::function &, Vector &)> p_function, std::function &, Matrix &)> p_jacobian, - Vector &p_x, double &p_omega, TerminationFunctionType p_terminate, + Vector &p_x, TraceDirection p_direction, size_t p_trackingIndex, + TerminationFunctionType p_terminate, CallbackFunctionType p_callback = NullCallbackFunction, CriterionFunctionType p_criterion = NullCriterionFunction, CriterionBracketFunctionType p_criterionBracker = NullCriterionBracketFunction) const; diff --git a/src/tools/logit/logit.cc b/src/tools/logit/logit.cc index a10fbebd2d..01f1a93ef5 100644 --- a/src/tools/logit/logit.cc +++ b/src/tools/logit/logit.cc @@ -206,7 +206,8 @@ int main(int argc, char *argv[]) } }; auto result = - LogitStrategyEstimate(frequencies, maxLambda, 1.0, false, hStart, maxDecel, printer); + LogitStrategyEstimate(frequencies, maxLambda, PathTracer::TraceDirection::Positive, + false, hStart, maxDecel, printer); PrintProfile(std::cout, decimals, result); return 0; } @@ -219,14 +220,15 @@ int main(int argc, char *argv[]) }; const LogitQREMixedStrategyProfile start(game); if (!targetLambda.empty()) { - auto result = - LogitStrategySolveLambda(start, targetLambda, 1.0, hStart, maxDecel, printer); + auto result = LogitStrategySolveLambda( + start, targetLambda, PathTracer::TraceDirection::Positive, hStart, maxDecel, printer); for (auto &profile : result) { PrintProfile(std::cout, decimals, profile); } } else { - auto result = LogitStrategySolve(start, maxregret, 1.0, hStart, maxDecel, printer); + auto result = LogitStrategySolve(start, maxregret, PathTracer::TraceDirection::Positive, + hStart, maxDecel, printer); PrintProfile(std::cout, decimals, result.back(), true); } } @@ -238,14 +240,15 @@ int main(int argc, char *argv[]) }; const LogitQREMixedBehaviorProfile start(game); if (!targetLambda.empty()) { - auto result = - LogitBehaviorSolveLambda(start, targetLambda, 1.0, hStart, maxDecel, printer); + auto result = LogitBehaviorSolveLambda( + start, targetLambda, PathTracer::TraceDirection::Positive, hStart, maxDecel, printer); for (auto &profile : result) { PrintProfile(std::cout, decimals, profile); } } else { - auto result = LogitBehaviorSolve(start, maxregret, 1.0, hStart, maxDecel, printer); + auto result = LogitBehaviorSolve(start, maxregret, PathTracer::TraceDirection::Positive, + hStart, maxDecel, printer); PrintProfile(std::cout, decimals, result.back(), true); } } From 59525dde8db02cb24f34ed2429c247567923f9a8 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 1 Sep 2026 20:18:22 +0100 Subject: [PATCH 05/11] Instrument HP with callbacks and cooperative cancel --- src/pygambit/callback.h | 24 ++++++++++++++++++++++++ src/pygambit/gambit.pxd | 12 ++++++------ src/pygambit/nash.h | 8 ++++++++ src/pygambit/nash.pxi | 25 +++++++++++++++++++++++-- src/pygambit/nash.py | 10 +++++++++- src/solvers/hp/hp.cc | 35 +++++++++++++---------------------- src/solvers/hp/hp.h | 34 ++++++++++++++++++++++++++++------ 7 files changed, 111 insertions(+), 37 deletions(-) diff --git a/src/pygambit/callback.h b/src/pygambit/callback.h index 7982c86b34..bd99dbbaab 100644 --- a/src/pygambit/callback.h +++ b/src/pygambit/callback.h @@ -33,6 +33,7 @@ #include "core/rational.h" #include "solvers/enumpoly/enumpoly.h" #include "solvers/gnm/gnm.h" +#include "solvers/hp/hp.h" #include "solvers/ipa/ipa.h" #include "solvers/liap/liap.h" #include "solvers/logit/logit.h" @@ -63,6 +64,10 @@ InvokeLogitStrategyEventCallback(PyObject *p_callback, std::string InvokeLogitBehaviorEventCallback(PyObject *p_callback, std::shared_ptr p_qre); +std::string +InvokeHPStrategyEventCallback(PyObject *p_callback, + std::shared_ptr> p_profile, + double p_t); std::string InvokeGNMPerturbationEventCallback( PyObject *p_callback, std::shared_ptr> p_profile); std::string @@ -228,6 +233,25 @@ MakeLogitEventCallback(PyObject *p_callbac }; } +/// +/// Builds an HPEventCallbackType which, when invoked with a point traced +/// along the HP homotopy path, calls a Python callable with the mixed +/// strategy profile and homotopy parameter t, converted to the +/// corresponding pygambit types. A null callback (Python `None`) yields the +/// solver's own no-op default. +/// +inline Gambit::Nash::HPEventCallbackType MakeHPEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullHPEventCallback; + } + return [p_callback](const Gambit::Nash::HPEvent &p_event) { + const auto &step = std::get(p_event); + Gambit::ThrowIfPythonError(InvokeHPStrategyEventCallback( + p_callback, std::make_shared>(step.profile), step.t)); + }; +} + /// /// Builds a Nash::GNMEventCallbackType which, when invoked, dispatches to /// whichever Invoke*EventCallback trampoline matches the alternative held by diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index c176d84d8b..a29b0f08e5 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -549,6 +549,8 @@ cdef extern from "callback.h": pass cppclass LogitEventCallbackType "Gambit::LogitEventCallbackType"[T]: pass + cppclass HPEventCallbackType "Gambit::Nash::HPEventCallbackType": + pass cppclass GNMEventCallbackType "Gambit::Nash::GNMEventCallbackType": pass cppclass LiapEventCallbackType "Gambit::Nash::LiapEventCallbackType"[T]: @@ -563,6 +565,7 @@ cdef extern from "callback.h": StrategyCallbackType[T] MakeStrategyCallback[T](object) BehaviorCallbackType[T] MakeBehaviorCallback[T](object) LogitEventCallbackType[T] MakeLogitEventCallback[T](object) + HPEventCallbackType MakeHPEventCallback(object) GNMEventCallbackType MakeGNMEventCallback(object) LiapEventCallbackType[T] MakeLiapEventCallback[T](object) SimpdivEventCallbackType MakeSimpdivEventCallback(object) @@ -663,12 +666,6 @@ cdef extern from "solvers/logit/logit.h": double getitem "operator[]"(int) except +IndexError -cdef extern from "solvers/hp/hp.h": - stdlist[c_MixedStrategyProfile[double]] HPStrategySolve( - c_MixedStrategyProfile[double] - ) except +RuntimeError - - cdef extern from "nash.h": pair[ stdlist[c_MixedStrategyProfile[T]], stdlist[stdlist[c_MixedStrategyProfile[T]]] @@ -703,3 +700,6 @@ cdef extern from "nash.h": shared_ptr[c_MixedStrategyProfile[double]], bool, double, double, LogitEventCallbackType[c_LogitQREMixedStrategyProfile] ) except + + stdlist[c_MixedStrategyProfile[double]] HPStrategySolveWrapper( + c_MixedStrategyProfile[double], HPEventCallbackType + ) except +RuntimeError diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 632f330f92..787fd60919 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -21,6 +21,7 @@ // #include "solvers/enummixed/enummixed.h" +#include "solvers/hp/hp.h" #include "solvers/logit/logit.h" #include "solvers/logit/path.h" @@ -140,3 +141,10 @@ LogitStrategyEstimateWrapper(std::shared_ptr> p_fre LogitStrategyEstimate(*p_frequencies, 1000000.0, PathTracer::TraceDirection::Positive, p_stopAtLocal, p_firstStep, p_maxAccel, p_onEvent)); } + +std::list> +HPStrategySolveWrapper(const MixedStrategyProfile &p_prior, + Nash::HPEventCallbackType p_onEvent = Nash::NullHPEventCallback) +{ + return Nash::HPStrategySolve(p_prior, Nash::NullStrategyCallback, p_onEvent); +} diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index f8ebac266e..e319846a3e 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -63,6 +63,13 @@ class GNMTerminationEvent: message: str +@dataclasses.dataclass(frozen=True) +class HPStepEvent: + """Reports one point traced along the HP homotopy path.""" + profile: MixedStrategyProfileDouble + t: float + + @dataclasses.dataclass(frozen=True) class LiapStartEvent: """Reports the starting point of a :ref:`Lyapunov function minimization ` run.""" @@ -207,6 +214,16 @@ cdef public string InvokeLogitBehaviorEventCallback( return b"" +cdef public string InvokeHPStrategyEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[float]], t: float +): + try: + callback(HPStepEvent(profile=MixedStrategyProfileDouble.wrap(profile), t=t)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + cdef public string InvokeGNMPerturbationEventCallback( callback, profile: shared_ptr[c_MixedStrategyProfile[float]] ): @@ -896,5 +913,9 @@ def _logit_behavior_branch(game: Game, def _hp_strategy_solve( - prior: MixedStrategyProfileDouble) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(HPStrategySolve(deref(prior.profile))) + prior: MixedStrategyProfileDouble, + event_callback: object = None, +) -> list[MixedStrategyProfileDouble]: + return _convert_mspd(HPStrategySolveWrapper( + deref(prior.profile), MakeHPEventCallback(event_callback) + )) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 95b3be189f..32aa3aa07c 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -1113,6 +1113,7 @@ def logit_solve( def hp_solve( prior: libgbt.MixedStrategyProfileDouble, + event_callback: Callable[[libgbt.HPStepEvent], None] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a game using :cite:p:`HerPee01` @@ -1124,12 +1125,19 @@ def hp_solve( prior : MixedStrategyProfileDouble The prior distribution over strategies. + event_callback : Callable[[HPStepEvent], None], optional + If specified, called with each point traced along the homotopy path, + and the homotopy parameter ``t`` at which it was reached, on the way + to the returned equilibrium. + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult The result represented as a ``NashComputationResult`` object. """ - equilibria = libgbt._hp_strategy_solve(prior) + equilibria = libgbt._hp_strategy_solve(prior, event_callback) return NashComputationResult( game=prior.game, method="hp", diff --git a/src/solvers/hp/hp.cc b/src/solvers/hp/hp.cc index 1e75d8dddb..5f7c303e2e 100644 --- a/src/solvers/hp/hp.cc +++ b/src/solvers/hp/hp.cc @@ -20,17 +20,17 @@ // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. // -#include #include "gambit.h" #include "solvers/hp/hp.h" #include "solvers/hp/hpsystem.h" #include "solvers/logit/path.h" -namespace Gambit { +namespace Gambit::Nash { std::list> -HPStrategySolve(const MixedStrategyProfile &p_prior) +HPStrategySolve(const MixedStrategyProfile &p_prior, + StrategyCallbackType p_onEquilibrium, HPEventCallbackType p_onEvent, + const CancelToken &p_cancel) { - std::list> equilibria; HPEquationSystem system(p_prior); @@ -43,30 +43,21 @@ HPStrategySolve(const MixedStrategyProfile &p_prior) auto criterion_function = [](const Vector &point, const Vector &tangent) -> double { return point[1] - 1.0; }; - const TracePathResult result = tracer.TracePath( + tracer.TracePath( [&system](const Vector &point, Vector &lhs) { system.GetValue(point, lhs); }, [&system](const Vector &point, Matrix &jac) { system.GetJacobian(point, jac); }, x, direction, tracking_index, termination_condition, - [&system](const Vector &point) { - std::cout << "[Path Tracer Step] t = " << point[1]; - std::cout << " | Alfas: "; - for (size_t i = 2; i <= 5; ++i) { - std::cout << point[i] << " "; - } - std::cout << "| Mu: " << point[6] << " " << point[7] << std::endl; - - std::cout << "Full point vector in probabilities: "; - Vector prob_vector = system.ExtractEquilibrium(point).GetProbVector(); - for (size_t i = 1; i <= prob_vector.size(); ++i) { - std::cout << prob_vector[i] << " "; - } - std::cout << std::endl; + [&system, &p_onEvent](const Vector &point) { + const MixedStrategyProfile profile = system.ExtractEquilibrium(point); + p_onEvent(HPStepEvent{.profile = profile, .t = point[1]}); }, - criterion_function); + criterion_function, NullCriterionBracketFunction, p_cancel); - equilibria.push_back(system.ExtractEquilibrium(x)); + const MixedStrategyProfile equilibrium = system.ExtractEquilibrium(x); + p_onEquilibrium(equilibrium); + equilibria.push_back(equilibrium); return equilibria; } -} // namespace Gambit +} // namespace Gambit::Nash diff --git a/src/solvers/hp/hp.h b/src/solvers/hp/hp.h index 2ed16a4303..4bc5a7280f 100644 --- a/src/solvers/hp/hp.h +++ b/src/solvers/hp/hp.h @@ -20,14 +20,36 @@ // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. // -#ifndef HP_H -#define HP_H +#ifndef GAMBIT_SOLVERS_HP_HP_H +#define GAMBIT_SOLVERS_HP_HP_H +#include #include +#include -namespace Gambit { +#include "solvers/nash.h" + +namespace Gambit::Nash { + +/// @brief Reports a point traced along the HP homotopy path, at homotopy parameter \p t +struct HPStepEvent { + const MixedStrategyProfile &profile; + double t; +}; + +using HPEvent = std::variant; +using HPEventCallbackType = std::function; + +inline void NullHPEventCallback(const HPEvent &) {} + +/// @brief Compute a Nash equilibrium of a game using the homotopy method of +/// Herings and Peeters (2001) std::list> -HPStrategySolve(const MixedStrategyProfile &p_prior); -} // namespace Gambit +HPStrategySolve(const MixedStrategyProfile &p_prior, + StrategyCallbackType p_onEquilibrium = NullStrategyCallback, + HPEventCallbackType p_onEvent = NullHPEventCallback, + const CancelToken &p_cancel = CancelToken()); + +} // namespace Gambit::Nash -#endif // HP_H +#endif // GAMBIT_SOLVERS_HP_HP_H From 4ba0e642926743d5fdf0502f79b1bc3a71eaf0d5 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 07:18:36 +0100 Subject: [PATCH 06/11] Add command-line wrapper for HP --- doc/pygambit.api.rst | 1 + doc/tools.rst | 1 + pyproject.toml | 1 + src/pygambit/cli/hp.py | 118 +++++++++++++++++++++++++++++++++++++ tests/cli/test_contract.py | 2 + tests/cli/test_hp.py | 96 ++++++++++++++++++++++++++++++ 6 files changed, 219 insertions(+) create mode 100644 src/pygambit/cli/hp.py create mode 100644 tests/cli/test_hp.py diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e8ef2c8a65..67b286597d 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -403,6 +403,7 @@ Computation of Nash equilibria simpdiv_solve ipa_solve gnm_solve + hp_solve Computation of quantal response equilibria diff --git a/doc/tools.rst b/doc/tools.rst index 2efab1a955..99445adf14 100644 --- a/doc/tools.rst +++ b/doc/tools.rst @@ -53,3 +53,4 @@ documentation. tools.logit tools.gnm tools.ipa + tools.hp diff --git a/pyproject.toml b/pyproject.toml index 9dd6042676..78ce2dedde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ gambit-simpdiv = "pygambit.cli.simpdiv:main" gambit-gnm = "pygambit.cli.gnm:main" gambit-ipa = "pygambit.cli.ipa:main" gambit-enumpoly = "pygambit.cli.enumpoly:main" +gambit-hp = "pygambit.cli.hp:main" [project.optional-dependencies] test = ["pytest", "pytest-subtests", "nbformat", "nbclient", "ipykernel"] diff --git a/src/pygambit/cli/hp.py b/src/pygambit/cli/hp.py new file mode 100644 index 0000000000..0daa664b61 --- /dev/null +++ b/src/pygambit/cli/hp.py @@ -0,0 +1,118 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/cli/hp.py +# Command-line driver program for Nash equilibrium computation via the +# Herings-Peeters (2001) homotopy 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 the +Herings-Peeters (2001) homotopy method. +""" + +from __future__ import annotations + +import click + +import pygambit as gbt + +from .common import ( + handle_errors, + load_game, + render_profile_csv, + resolve_strategy_starts, + version_option, +) + +DESCRIPTION = "Compute a Nash equilibrium using the Herings-Peeters (2001) homotopy method" +PROG_NAME = "gambit-hp" + + +@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_priors", + type=int, + default=None, + help="number of prior distributions to generate randomly (mutually exclusive with -s)", +) +@click.option( + "-R", + "seed", + type=int, + default=None, + help="seed the random number generator used to generate prior distributions " + "(default is to seed from system entropy); requires -n", +) +@click.option( + "-s", + "start_file", + type=str, + default=None, + help="file containing prior distributions (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 each point traced along the homotopy path)", +) +@version_option(DESCRIPTION) +@handle_errors +def main( + file: str | None, + decimals: int, + n_priors: int | None, + seed: int | None, + start_file: str | None, + quiet: bool, + verbose: bool, +) -> None: + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) + priors = resolve_strategy_starts(game, n_priors, seed, start_file) + + def render_event(event: gbt.HPStepEvent) -> None: + if verbose: + click.echo(render_profile_csv(event.profile, f"{event.t:.6g}", decimals)) + + for prior in priors: + prior = prior.as_float() + if verbose: + click.echo(render_profile_csv(prior, "prior", decimals)) + result = gbt.nash.hp_solve(prior, event_callback=render_event) + for eq in result.equilibria: + click.echo(render_profile_csv(eq, "NE", decimals)) + + +if __name__ == "__main__": + main() diff --git a/tests/cli/test_contract.py b/tests/cli/test_contract.py index f836ebc50c..d1d9b4f68f 100644 --- a/tests/cli/test_contract.py +++ b/tests/cli/test_contract.py @@ -12,6 +12,7 @@ enumpoly, enumpure, gnm, + hp, ipa, lcp, liap, @@ -31,6 +32,7 @@ gnm, ipa, enumpoly, + hp, ] diff --git a/tests/cli/test_hp.py b/tests/cli/test_hp.py new file mode 100644 index 0000000000..5fa4991024 --- /dev/null +++ b/tests/cli/test_hp.py @@ -0,0 +1,96 @@ +"""Tests that gambit-hp's switches produce the behavior they document.""" + +from pygambit.cli import hp + + +def _start_file(tmp_path, text="0.9,0.1,0.9,0.1\n"): + path = tmp_path / "prior.csv" + path.write_text(text) + return path + + +def test_default_reports_one_equilibrium_per_prior( + cli_runner, nfg_asymmetric_table_text, tmp_path +): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + hp.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"] + + +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( + hp.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"] + + +def test_decimals_flag_changes_precision(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + result = cli_runner.invoke( + hp.main, ["-q", "-d", "2", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert result.exit_code == 0 + assert result.stdout.strip().splitlines() == ["NE,1.00,0.00,1.00,0.00"] + + +def test_verbose_flag_adds_prior_and_step_lines(cli_runner, nfg_asymmetric_table_text, tmp_path): + start_file = _start_file(tmp_path) + plain = cli_runner.invoke( + hp.main, ["-q", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + verbose = cli_runner.invoke( + hp.main, ["-q", "-V", "-s", str(start_file)], input=nfg_asymmetric_table_text + ) + assert verbose.exit_code == 0 + lines = verbose.stdout.strip().splitlines() + # The first line reports the (generally mixed) prior itself; the second is the + # t=0 step, the best response to that prior, which is pure by construction. + assert lines[0] == "prior,0.900000,0.100000,0.900000,0.100000" + assert lines[1].startswith("0,") + assert len(lines[1].split(",")) == 5 + 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( + hp.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(hp.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_priors_reported(cli_runner, nfg_asymmetric_table_text): + one = cli_runner.invoke(hp.main, ["-q", "-n", "1", "-R", "1"], input=nfg_asymmetric_table_text) + three = cli_runner.invoke( + hp.main, ["-q", "-n", "3", "-R", "1"], input=nfg_asymmetric_table_text + ) + assert one.exit_code == 0 + assert three.exit_code == 0 + # Each prior contributes exactly one reported equilibrium. + assert len(one.stdout.strip().splitlines()) == 1 + assert len(three.stdout.strip().splitlines()) == 3 + + +def test_degenerate_prior_is_a_clean_error(cli_runner, nfg_coordination_text, tmp_path): + # A prior exactly tied between two best responses has no unique best response + # at t=0, which HPStrategySolve rejects rather than picking one arbitrarily. + start_file = _start_file(tmp_path, "0.5,0.5,0.5,0.5\n") + result = cli_runner.invoke( + hp.main, ["-q", "-s", str(start_file)], input=nfg_coordination_text + ) + assert result.exit_code == 1 + assert "Multiple best responses" in result.stderr From c1b99b2dcdae7faa010bddc8d54985f6110eb9cc Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 07:41:56 +0100 Subject: [PATCH 07/11] Expose HP as an option in the GUI --- Makefile.am | 5 +++-- src/gui/dllogit.h | 8 ++++---- src/gui/dlnash.cc | 16 +++++++++++++++- src/gui/nashspec.cc | 20 ++++++++++++++++++-- src/gui/nashspec.h | 22 +++++++++++++++++----- 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/Makefile.am b/Makefile.am index 9abfdf7a55..ef1c4a6536 100644 --- a/Makefile.am +++ b/Makefile.am @@ -363,13 +363,14 @@ AM_CXXFLAGS = ${LLVM_CXXFLAGS} ## recompiling the same core/games/solver sources from scratch. noinst_LIBRARIES = libcore.a libgames.a libbimatrix.a libgtracer.a \ - libliap.a liblogit.a libsimpdiv.a libenumpoly.a + libliap.a liblogit.a libsimpdiv.a libenumpoly.a libhp.a libcore_a_SOURCES = ${core_SOURCES} libgames_a_SOURCES = ${game_SOURCES} libbimatrix_a_SOURCES = ${bimatrix_SOURCES} libgtracer_a_SOURCES = ${gtracerlib_SOURCES} libliap_a_SOURCES = ${liap_SOURCES} +libhp_a_SOURCES = ${hp_SOURCES} liblogit_a_SOURCES = ${logit_SOURCES} libsimpdiv_a_SOURCES = ${simpdiv_SOURCES} libenumpoly_a_SOURCES = ${enumpoly_SOURCES} @@ -450,7 +451,7 @@ gambit_CXXFLAGS = $(AM_CXXFLAGS) $(WX_CXXFLAGS) gambit_CPPFLAGS = $(AM_CPPFLAGS) $(WX_CXXFLAGS) gambit_LDADD_LIBS = libbimatrix.a libliap.a liblogit.a libgtracer.a \ - libsimpdiv.a libenumpoly.a libgames.a libcore.a + libsimpdiv.a libenumpoly.a libhp.a libgames.a libcore.a gambit_DEPENDENCIES = $(RC_OBJECT_PATH) $(gambit_LDADD_LIBS) diff --git a/src/gui/dllogit.h b/src/gui/dllogit.h index d6462b2cc0..c946e64198 100644 --- a/src/gui/dllogit.h +++ b/src/gui/dllogit.h @@ -87,8 +87,8 @@ struct BehavLogitTraits { const CancelToken &p_cancel) { const QREType start(p_game); - LogitBehaviorSolve(start, 1.0e-8, 1.0, 0.03, 1.1, Nash::NullBehaviorCallback, - p_onEvent, p_cancel); + LogitBehaviorSolve(start, 1.0e-8, PathTracer::TraceDirection::Positive, 0.03, 1.1, + Nash::NullBehaviorCallback, p_onEvent, p_cancel); } }; @@ -123,8 +123,8 @@ struct MixedLogitTraits { const CancelToken &p_cancel) { const QREType start(p_game); - LogitStrategySolve(start, 1.0e-8, 1.0, 0.03, 1.1, Nash::NullStrategyCallback, - p_onEvent, p_cancel); + LogitStrategySolve(start, 1.0e-8, PathTracer::TraceDirection::Positive, 0.03, 1.1, + Nash::NullStrategyCallback, p_onEvent, p_cancel); } }; diff --git a/src/gui/dlnash.cc b/src/gui/dlnash.cc index 47c2a47a0b..702b3b63d2 100644 --- a/src/gui/dlnash.cc +++ b/src/gui/dlnash.cc @@ -38,6 +38,7 @@ static wxString s_enumpure(wxT("by looking for pure strategy equilibria")); static wxString s_enummixed(wxT("by enumerating extreme points")); static wxString s_enumpoly(wxT("by solving systems of polynomial equations")); static wxString s_gnm(wxT("by global Newton tracing")); +static wxString s_hp(wxT("by the Herings-Peeters homotopy")); static wxString s_ipa(wxT("by iterated polymatrix approximation")); static wxString s_lp(wxT("by solving a linear program")); static wxString s_lcp(wxT("by solving a linear complementarity program")); @@ -91,6 +92,9 @@ NashMethodSpec ResolveMethod(const wxString &p_method, NashEquilibriumTarget p_t if (p_method == s_gnm) { return GNMNashSpec{}; } + if (p_method == s_hp) { + return HPNashSpec{}; + } if (p_method == s_ipa) { return IPANashSpec{}; } @@ -118,7 +122,7 @@ NashMethodSpec ResolveMethod(const wxString &p_method, NashEquilibriumTarget p_t template concept StrategicMethod = std::same_as || std::same_as || - std::same_as || std::same_as || + std::same_as || std::same_as || std::same_as || std::same_as || std::same_as; template @@ -170,6 +174,9 @@ wxString ExternalCommand(const NashComputationSpec &p_spec) method.localNewtonInterval, method.localNewtonMaxIterations); } + else if constexpr (std::is_same_v) { + return prefix + wxString::Format("hp -d 10 -n %d", method.priors); + } else if constexpr (std::is_same_v) { return prefix + wxString::Format("ipa -d 10 -n %d", method.perturbations); } @@ -223,6 +230,9 @@ wxString MethodDescription(const NashMethodSpec &p_method) else if constexpr (std::is_same_v) { return wxT("by global Newton tracing"); } + else if constexpr (std::is_same_v) { + return wxT("by the Herings-Peeters homotopy"); + } else if constexpr (std::is_same_v) { return wxT("by iterated polymatrix approximation"); } @@ -263,6 +273,9 @@ wxString ParameterDescription(const NashMethodSpec &p_method) method.perturbations, method.lambdaEnd, method.steps, method.localNewtonInterval, method.localNewtonMaxIterations); } + else if constexpr (std::is_same_v) { + return wxString::Format(" (%d random priors)", method.priors); + } else if constexpr (std::is_same_v) { return wxString::Format(" (%d perturbation)", method.perturbations); } @@ -387,6 +400,7 @@ void NashChoiceDialog::OnCount(wxCommandEvent &p_event) m_methodChoice->Append(s_liap); m_methodChoice->Append(s_gnm); m_methodChoice->Append(s_ipa); + m_methodChoice->Append(s_hp); m_methodChoice->Append(s_enumpoly); } else { diff --git a/src/gui/nashspec.cc b/src/gui/nashspec.cc index 4c8ee29a23..77e5d17f31 100644 --- a/src/gui/nashspec.cc +++ b/src/gui/nashspec.cc @@ -26,6 +26,7 @@ #include "solvers/enumpoly/enumpoly.h" #include "solvers/enumpure/enumpure.h" #include "solvers/gnm/gnm.h" +#include "solvers/hp/hp.h" #include "solvers/ipa/ipa.h" #include "solvers/lcp/lcp.h" #include "solvers/liap/liap.h" @@ -95,6 +96,21 @@ std::optional GNMNashSpec::MakeSolver(NashRepresentation) const }; } +std::optional HPNashSpec::MakeSolver(NashRepresentation) const +{ + const HPNashSpec spec = *this; + return [spec](const Game &p_game, const ProfileFoundCallback &p_callback, + const CancelToken &p_cancel) { + for (const auto &prior : NewRandomStrategyProfiles(p_game, spec.priors)) { + p_cancel.Check(); + Nash::HPStrategySolve( + prior, + [&p_callback](const MixedStrategyProfile &p) { p_callback(ComputedProfile(p)); }, + Nash::NullHPEventCallback, p_cancel); + } + }; +} + std::optional IPANashSpec::MakeSolver(NashRepresentation) const { const IPANashSpec spec = *this; @@ -178,7 +194,7 @@ std::optional LogitNashSpec::MakeSolver(NashRepresentation p_rep const CancelToken &p_cancel) { const LogitQREMixedBehaviorProfile start(p_game); LogitBehaviorSolve( - start, spec.maxRegret, spec.omega, spec.firstStep, spec.maxAcceleration, + start, spec.maxRegret, spec.direction, spec.firstStep, spec.maxAcceleration, [&p_callback](const MixedBehaviorProfile &p) { p_callback(ComputedProfile(p)); }, NullLogitEventCallback, p_cancel); }; @@ -187,7 +203,7 @@ std::optional LogitNashSpec::MakeSolver(NashRepresentation p_rep const CancelToken &p_cancel) { const LogitQREMixedStrategyProfile start(p_game); LogitStrategySolve( - start, spec.maxRegret, spec.omega, spec.firstStep, spec.maxAcceleration, + start, spec.maxRegret, spec.direction, spec.firstStep, spec.maxAcceleration, [&p_callback](const MixedStrategyProfile &p) { p_callback(ComputedProfile(p)); }, NullLogitEventCallback, p_cancel); }; diff --git a/src/gui/nashspec.h b/src/gui/nashspec.h index 50af9a87d3..51698a8ed2 100644 --- a/src/gui/nashspec.h +++ b/src/gui/nashspec.h @@ -27,8 +27,11 @@ #include #include "core/cancel.h" -#include "solvers/nash.h" +#include "core/matrix.h" #include "core/rational.h" +#include "core/vector.h" +#include "solvers/logit/path.h" +#include "solvers/nash.h" namespace Gambit::GUI { @@ -94,6 +97,15 @@ struct IPANashSpec { std::optional MakeSolver(NashRepresentation) const; }; +struct HPNashSpec { + // Unlike GNM/IPA, a single prior yields at most one equilibrium (no internal + // path-tracing can surface more), so this defaults high, like LiapNashSpec's + // startingPoints, rather than to 1. + int priors{10}; + + std::optional MakeSolver(NashRepresentation) const; +}; + struct LPNashSpec { std::optional MakeSolver(NashRepresentation p_representation) const; }; @@ -115,7 +127,7 @@ struct LiapNashSpec { struct LogitNashSpec { double maxRegret{1.0e-8}; - double omega{1.0}; + PathTracer::TraceDirection direction{PathTracer::TraceDirection::Positive}; double firstStep{0.03}; double maxAcceleration{1.1}; @@ -132,9 +144,9 @@ struct SimpdivNashSpec { std::optional MakeSolver(NashRepresentation) const; }; -using NashMethodSpec = - std::variant; +using NashMethodSpec = std::variant; struct NashComputationSpec { NashRepresentation representation; From ea8a55e525b330bee93bc0e7eedf1dcd93bbb5e9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 08:02:48 +0100 Subject: [PATCH 08/11] Reorganise path-following methods internally. --- Makefile.am | 20 +++++++++++++------- setup.py | 7 +++---- src/gui/nashspec.h | 2 +- src/pygambit/nash.h | 2 +- src/solvers/hp/hp.cc | 2 +- src/solvers/logit/efglogit.cc | 2 +- src/solvers/logit/logit.h | 2 +- src/solvers/logit/nfglogit.cc | 2 +- src/solvers/{logit => path}/path.cc | 2 +- src/solvers/{logit => path}/path.h | 2 +- 10 files changed, 24 insertions(+), 19 deletions(-) rename src/solvers/{logit => path}/path.cc (99%) rename src/solvers/{logit => path}/path.h (99%) diff --git a/Makefile.am b/Makefile.am index ef1c4a6536..44fe6c2d6f 100644 --- a/Makefile.am +++ b/Makefile.am @@ -312,15 +312,22 @@ liap_SOURCES = \ src/solvers/liap/nfgliap.cc \ src/solvers/liap/liap.h +path_SOURCES = \ + src/solvers/path/path.cc \ + src/solvers/path/path.h + logit_SOURCES = \ src/solvers/logit/logbehav.h \ src/solvers/logit/logbehav.imp \ - src/solvers/logit/path.cc \ - src/solvers/logit/path.h \ src/solvers/logit/logit.h \ src/solvers/logit/efglogit.cc \ src/solvers/logit/nfglogit.cc +homotopy_SOURCES = \ + ${path_SOURCES} \ + ${logit_SOURCES} \ + ${hp_SOURCES} + simpdiv_SOURCES = \ src/solvers/simpdiv/simpdiv.cc \ src/solvers/simpdiv/simpdiv.h @@ -363,15 +370,14 @@ AM_CXXFLAGS = ${LLVM_CXXFLAGS} ## recompiling the same core/games/solver sources from scratch. noinst_LIBRARIES = libcore.a libgames.a libbimatrix.a libgtracer.a \ - libliap.a liblogit.a libsimpdiv.a libenumpoly.a libhp.a + libliap.a libhomotopy.a libsimpdiv.a libenumpoly.a libcore_a_SOURCES = ${core_SOURCES} libgames_a_SOURCES = ${game_SOURCES} libbimatrix_a_SOURCES = ${bimatrix_SOURCES} libgtracer_a_SOURCES = ${gtracerlib_SOURCES} libliap_a_SOURCES = ${liap_SOURCES} -libhp_a_SOURCES = ${hp_SOURCES} -liblogit_a_SOURCES = ${logit_SOURCES} +libhomotopy_a_SOURCES = ${homotopy_SOURCES} libsimpdiv_a_SOURCES = ${simpdiv_SOURCES} libenumpoly_a_SOURCES = ${enumpoly_SOURCES} @@ -450,8 +456,8 @@ gambit_SOURCES = \ gambit_CXXFLAGS = $(AM_CXXFLAGS) $(WX_CXXFLAGS) gambit_CPPFLAGS = $(AM_CPPFLAGS) $(WX_CXXFLAGS) -gambit_LDADD_LIBS = libbimatrix.a libliap.a liblogit.a libgtracer.a \ - libsimpdiv.a libenumpoly.a libhp.a libgames.a libcore.a +gambit_LDADD_LIBS = libbimatrix.a libliap.a libhomotopy.a libgtracer.a \ + libsimpdiv.a libenumpoly.a libgames.a libcore.a gambit_DEPENDENCIES = $(RC_OBJECT_PATH) $(gambit_LDADD_LIBS) diff --git a/setup.py b/setup.py index 4a9e213469..35a70b8410 100644 --- a/setup.py +++ b/setup.py @@ -95,11 +95,10 @@ def run(self) -> None: cppgambit_bimatrix = solver_library_config("cppgambit_bimatrix", ["linalg", "lp", "lcp", "enummixed"]) cppgambit_liap = solver_library_config("cppgambit_liap", ["liap"]) -cppgambit_logit = solver_library_config("cppgambit_logit", ["logit"]) +cppgambit_homotopy = solver_library_config("cppgambit_homotopy", ["path", "logit", "hp"]) cppgambit_gtracer = solver_library_config("cppgambit_gtracer", ["gtracer", "ipa", "gnm"]) cppgambit_simpdiv = solver_library_config("cppgambit_simpdiv", ["simpdiv"]) cppgambit_enumpoly = solver_library_config("cppgambit_enumpoly", ["nashsupport", "enumpoly"]) -cppgambit_hp = solver_library_config("cppgambit_hp", ["hp"]) libgambit = setuptools.Extension( @@ -112,8 +111,8 @@ def run(self) -> None: setuptools.setup( cmdclass={"build_py": GambitBuildPy}, - libraries=[cppgambit_bimatrix, cppgambit_liap, cppgambit_logit, cppgambit_simpdiv, - cppgambit_gtracer, cppgambit_enumpoly, cppgambit_hp, + libraries=[cppgambit_bimatrix, cppgambit_liap, cppgambit_homotopy, cppgambit_simpdiv, + cppgambit_gtracer, cppgambit_enumpoly, cppgambit_games, cppgambit_core], ext_modules=Cython.Build.cythonize(libgambit, language_level="3str", diff --git a/src/gui/nashspec.h b/src/gui/nashspec.h index 51698a8ed2..de07b4d933 100644 --- a/src/gui/nashspec.h +++ b/src/gui/nashspec.h @@ -30,8 +30,8 @@ #include "core/matrix.h" #include "core/rational.h" #include "core/vector.h" -#include "solvers/logit/path.h" #include "solvers/nash.h" +#include "solvers/path/path.h" namespace Gambit::GUI { diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 787fd60919..75dbfb2e20 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -23,7 +23,7 @@ #include "solvers/enummixed/enummixed.h" #include "solvers/hp/hp.h" #include "solvers/logit/logit.h" -#include "solvers/logit/path.h" +#include "solvers/path/path.h" using namespace std; using namespace Gambit; diff --git a/src/solvers/hp/hp.cc b/src/solvers/hp/hp.cc index 5f7c303e2e..25b06325c2 100644 --- a/src/solvers/hp/hp.cc +++ b/src/solvers/hp/hp.cc @@ -23,7 +23,7 @@ #include "gambit.h" #include "solvers/hp/hp.h" #include "solvers/hp/hpsystem.h" -#include "solvers/logit/path.h" +#include "solvers/path/path.h" namespace Gambit::Nash { std::list> diff --git a/src/solvers/logit/efglogit.cc b/src/solvers/logit/efglogit.cc index f82ee94899..093460e0f4 100644 --- a/src/solvers/logit/efglogit.cc +++ b/src/solvers/logit/efglogit.cc @@ -26,7 +26,7 @@ #include "games.h" #include "logit.h" #include "logbehav.imp" -#include "path.h" +#include "solvers/path/path.h" namespace { diff --git a/src/solvers/logit/logit.h b/src/solvers/logit/logit.h index eb24bce6ca..a8f0faded2 100644 --- a/src/solvers/logit/logit.h +++ b/src/solvers/logit/logit.h @@ -26,8 +26,8 @@ #include #include -#include "solvers/logit/path.h" #include "solvers/nash.h" +#include "solvers/path/path.h" namespace Gambit { diff --git a/src/solvers/logit/nfglogit.cc b/src/solvers/logit/nfglogit.cc index e473aa0b1b..56fc3cdf08 100644 --- a/src/solvers/logit/nfglogit.cc +++ b/src/solvers/logit/nfglogit.cc @@ -25,7 +25,7 @@ #include "games.h" #include "logit.h" -#include "path.h" +#include "solvers/path/path.h" namespace Gambit { diff --git a/src/solvers/logit/path.cc b/src/solvers/path/path.cc similarity index 99% rename from src/solvers/logit/path.cc rename to src/solvers/path/path.cc index 01d0cf9390..0a6fcba301 100644 --- a/src/solvers/logit/path.cc +++ b/src/solvers/path/path.cc @@ -2,7 +2,7 @@ // This file is part of Gambit // Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) // -// FILE: src/solvers/logit/path.cc +// FILE: src/solvers/path/path.cc // Implementation of generic smooth path-following algorithm. // // This program is free software; you can redistribute it and/or modify diff --git a/src/solvers/logit/path.h b/src/solvers/path/path.h similarity index 99% rename from src/solvers/logit/path.h rename to src/solvers/path/path.h index 8a7a935267..d05b1c9adf 100644 --- a/src/solvers/logit/path.h +++ b/src/solvers/path/path.h @@ -2,7 +2,7 @@ // This file is part of Gambit // Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) // -// FILE: src/solvers/logit/path.h +// FILE: src/solvers/path/path.h // Interface to generic smooth path-following algorithm. // // This program is free software; you can redistribute it and/or modify From 7f1b71ea3ba57632e3d2e600ac7e7db9aa4d4ec2 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 09:04:31 +0100 Subject: [PATCH 09/11] Integrate HP tests into main Nash tests --- pyproject.toml | 1 + tests/games.py | 9 ++++ tests/test_hp.py | 129 --------------------------------------------- tests/test_nash.py | 48 +++++++++++++++++ 4 files changed, 58 insertions(+), 129 deletions(-) delete mode 100644 tests/test_hp.py diff --git a/pyproject.toml b/pyproject.toml index 78ce2dedde..b3c9e30c48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ markers = [ "nash_logit_behavior: tests of logit_solve in behavior strategies", "nash_gnm_strategy: tests of gnm_solve in mixed strategies", "nash_ipa_strategy: tests of lpa_solve in mixed strategies", + "nash_hp_strategy: tests of hp_solve in mixed strategies", "nash_simpdiv: tests of simpdiv_solve (in mixed strategies)", "nash_liap_strategy: tests of liap_solve (in mixed strategies)", "nash_liap_agent: tests of liap_agent_solve (in mixed behaviors)", diff --git a/tests/games.py b/tests/games.py index 07fc521c73..7e2ee54f7f 100644 --- a/tests/games.py +++ b/tests/games.py @@ -112,6 +112,15 @@ def create_efg_corresponding_to_bimatrix_game(g: gbt.Game) -> gbt.Game: return create_efg_corresponding_to_bimatrix_game_arrays(A, B, g.title) +def create_hs1988_base_game() -> gbt.Game: + """The base 2x2 game used in all examples from Harsanyi & Selten (1988) Section 4.11, + also featured as Figure 1 of Herings & Peeters (2001). + """ + p1_payoffs = np.array([[2, 0], [0, 1]]) + p2_payoffs = np.array([[1, 0], [0, 4]]) + return gbt.Game.from_arrays(p1_payoffs, p2_payoffs, title="HS 1988 Base Game") + + ################################################################################################ # Extensive-form games (efg) diff --git a/tests/test_hp.py b/tests/test_hp.py deleted file mode 100644 index 9c5bcf1508..0000000000 --- a/tests/test_hp.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Test of calls to the Herings & Peeters (2001) homotopy solver.""" - -import dataclasses -import typing - -import numpy as np -import pytest - -import pygambit as gbt - -TOL = 1e-6 - - -def d(*probs) -> tuple: - """Helper function to let us write d() to be suggestive of - "probability distribution on simplex" ("Delta") - """ - return tuple(probs) - - -@dataclasses.dataclass -class HPSolverTestCase: - """Summarising the data relevant for a test fixture of a call to the HP solver.""" - factory: typing.Callable[[], gbt.MixedStrategyProfileDouble] - expected: list - prob_tol: float = TOL - - -def create_hs_base_game() -> gbt.Game: - """Creates the base 2x2 game used in all examples from Harsanyi & Selten (1988) Section 4.11 - and also featured in Herings & Peeters (2001). - """ - p1_payoffs = np.array([[2, 0], [0, 1]]) - p2_payoffs = np.array([[1, 0], [0, 4]]) - return gbt.Game.from_arrays(p1_payoffs, p2_payoffs, title="HS 1988 Base Game") - - -def create_hp_paper_example() -> gbt.MixedStrategyProfileDouble: - """Creates the example from Herings & Peeters (2001) Figure 1. - Also used in Harsanyi & Selten (1988) Section 4.11. -Second Example.""" - game = create_hs_base_game() - prior = game.mixed_strategy_profile() - p1, p2 = list(game.players) - s1, s2 = list(game.get_strategies(p1)), list(game.get_strategies(p2)) - - prior[p1] = {s1[0]: 0.5, s1[1]: 0.5} - prior[p2] = {s2[0]: 2.0 / 3.0, s2[1]: 1.0 / 3.0} - - return prior - - -def create_hs_example_1() -> gbt.MixedStrategyProfileDouble: - """Harsanyi & Selten (1988) Section 4.11 - First Example.""" - game = create_hs_base_game() - prior = game.mixed_strategy_profile() - p1, p2 = list(game.players) - s1, s2 = list(game.get_strategies(p1)), list(game.get_strategies(p2)) - - prior[p1] = {s1[0]: 1.0 / 3.0, s1[1]: 2.0 / 3.0} - prior[p2] = {s2[0]: 1.0 / 6.0, s2[1]: 5.0 / 6.0} - - return prior - - -def create_t0_degenerate_example() -> gbt.MixedStrategyProfileDouble: - """A prior that causes multiple best responses exactly at t=0.""" - game = create_hs_base_game() - prior = game.mixed_strategy_profile() - p1, p2 = list(game.players) - s1, s2 = list(game.get_strategies(p1)), list(game.get_strategies(p2)) - - prior[p1] = {s1[0]: 2.0 / 3.0, s1[1]: 1.0 / 3.0} - prior[p2] = {s2[0]: 1.0 / 3.0, s2[1]: 2.0 / 3.0} - - return prior - - -HP_CASES = [ - pytest.param( - HPSolverTestCase( - factory=create_hp_paper_example, - expected=[d(0.0, 1.0), d(0.0, 1.0)], - ), - id="test_hp_herings_peeters_example", - ), - pytest.param( - HPSolverTestCase( - factory=create_hs_example_1, - expected=[d(0.0, 1.0), d(0.0, 1.0)], - ), - id="test_hp_hs_example_1", - ), -] - - -@pytest.mark.nash -@pytest.mark.parametrize("test_case", HP_CASES) -def test_hp_strategy_solver(test_case: HPSolverTestCase, subtests) -> None: - """Test calls of the HP solver with starting priors. - - Subtests: - - Number of equilibria found is exactly 1. - - Equilibrium profile matches the expected theoretical result. - """ - prior = test_case.factory() - game = prior.game - - result = gbt.nash.hp_solve(prior=prior) - - with subtests.test("number of equilibria found"): - # The HP method uniquely selects exactly 1 equilibrium. - assert len(result.equilibria) == 1 - - eq = result.equilibria[0] - expected = game.mixed_strategy_profile(rational=False, data=test_case.expected) - - with subtests.test("strategy_profile matches expected"): - for player in game.players: - for strategy in game.get_strategies(player): - assert abs(eq[player][strategy] - expected[player][strategy]) <= test_case.prob_tol - - -@pytest.mark.nash -def test_hp_degenerate_t0_prior_raises_error() -> None: - """Test that the HP solver correctly identifies when given a degenerate prior.""" - prior = create_t0_degenerate_example() - with pytest.raises(RuntimeError, match="Multiple best responses found for player 1. " - "Only one best response is allowed."): - gbt.nash.hp_solve(prior=prior) diff --git a/tests/test_nash.py b/tests/test_nash.py index d49b599686..80030858e4 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1696,6 +1696,36 @@ def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: ] +HP_STRATEGY_CASES = [ + pytest.param( + EquilibriumTestCaseWithStart( + factory=games.create_hs1988_base_game, + solver=gbt.nash.hp_solve, + start_data=dict(data=[[0.5, 0.5], [2.0 / 3.0, 1.0 / 3.0]], rational=False), + expected=[[d(0.0, 1.0), d(0.0, 1.0)]], + regret_tol=TOL_LARGE, + prob_tol=TOL_LARGE, + ), + marks=pytest.mark.nash_hp_strategy, + id="test_hp_herings_peeters_example", + ), + pytest.param( + EquilibriumTestCaseWithStart( + factory=games.create_hs1988_base_game, + solver=gbt.nash.hp_solve, + start_data=dict( + data=[[1.0 / 3.0, 2.0 / 3.0], [1.0 / 6.0, 5.0 / 6.0]], rational=False + ), + expected=[[d(0.0, 1.0), d(0.0, 1.0)]], + regret_tol=TOL_LARGE, + prob_tol=TOL_LARGE, + ), + marks=pytest.mark.nash_hp_strategy, + id="test_hp_hs_example_1", + ), +] + + SIMPDIV_CASES = [ pytest.param( EquilibriumTestCaseWithStart( @@ -1718,6 +1748,7 @@ def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: CASES = [] CASES += LIAP_STRATEGY_CASES +CASES += HP_STRATEGY_CASES CASES += SIMPDIV_CASES @@ -1748,6 +1779,23 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s assert abs(eq_prob - exp_prob) <= test_case.prob_tol +@pytest.mark.nash +@pytest.mark.nash_hp_strategy +def test_hp_degenerate_t0_prior_raises_error() -> None: + """hp_solve() rejects a prior without a unique best response for some player at t=0, + rather than picking one of the tied best responses arbitrarily. + """ + game = games.create_hs1988_base_game() + prior = game.mixed_strategy_profile( + data=[[2.0 / 3.0, 1.0 / 3.0], [1.0 / 3.0, 2.0 / 3.0]], rational=False + ) + with pytest.raises( + RuntimeError, + match="Multiple best responses found for player 1. Only one best response is allowed.", + ): + gbt.nash.hp_solve(prior) + + ################################################################################################## # NASH SOLVER IN MIXED BEHAVIORS ################################################################################################## From 82b153e41cd732cb0949516393cc0afbbae60379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Fern=C3=A1ndez=20Cervell?= <153729439+AndresFerCervell@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:31:03 +0200 Subject: [PATCH 10/11] HP Documentation (#1120) Adds documentation of HP algorithm in `doc/algorithms.rst` and `doc/gui.nash.rst`. --- .github/workflows/osxbinary.yml | 5 + .github/workflows/wheels.yml | 21 + ChangeLog | 35 + doc/algorithms.rst | 20 +- doc/gui.nash.rst | 1 + doc/pygambit.api.rst | 90 +- doc/tools.hp.rst | 76 + doc/tutorials/02_extensive_form.ipynb | 38 +- doc/tutorials/03_stripped_down_poker.ipynb | 169 +- .../agent_versus_non_agent_regret.ipynb | 2 +- .../h_selector_prototype.ipynb | 454 ++++ .../openspiel.ipynb | 10 +- src/games/game.cc | 39 + src/games/game.h | 11 + src/games/gametable.cc | 3 +- src/games/gametree.cc | 15 + src/games/gametree.h | 1 + src/gui/efgtooltip.cc | 15 +- src/pygambit/behavmixed.pxi | 388 ++-- src/pygambit/behavspt.pxi | 180 +- src/pygambit/catalog.py | 8 +- src/pygambit/cli/common.py | 74 +- src/pygambit/gambit.pxd | 4 +- src/pygambit/gambit.pyx | 17 +- src/pygambit/game.pxi | 1914 ++++++++++++----- src/pygambit/gamecollections.pxi | 124 +- src/pygambit/gamehelpers.pxi | 32 +- src/pygambit/hsel.pxi | 319 +++ src/pygambit/infoset.pxi | 220 -- src/pygambit/node.pxi | 376 +--- src/pygambit/outcome.pxi | 160 -- src/pygambit/qre.py | 20 +- src/pygambit/strategy.pxi | 109 +- src/pygambit/stratspt.pxi | 2 +- tests/cli/conftest.py | 17 +- tests/games.py | 313 ++- tests/test_actions.py | 358 --- tests/test_behav.py | 410 ++-- tests/test_behavspt_profiles.py | 151 +- tests/test_catalog.py | 8 +- tests/test_extensive.py | 33 +- tests/test_file.py | 10 +- tests/test_game.py | 208 -- tests/test_game_resolve.py | 37 +- tests/test_hsel.py | 118 + tests/test_infosets.py | 503 ----- tests/test_nash.py | 38 +- tests/test_node.py | 1339 ------------ tests/test_outcome_mutations.py | 194 ++ tests/test_outcome_queries.py | 84 + tests/test_outcomes.py | 218 -- tests/test_players.py | 12 +- tests/test_profile_invalidation.py | 130 ++ tests/test_qre.py | 16 +- ...rategic.py => test_strategic_mutations.py} | 45 - tests/test_strategic_queries.py | 130 ++ tests/test_tree_mutations.py | 997 +++++++++ tests/test_tree_queries.py | 533 +++++ 58 files changed, 5883 insertions(+), 4971 deletions(-) create mode 100644 doc/tools.hp.rst create mode 100644 doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb create mode 100644 src/pygambit/hsel.pxi delete mode 100644 src/pygambit/infoset.pxi delete mode 100644 src/pygambit/outcome.pxi delete mode 100644 tests/test_actions.py create mode 100644 tests/test_hsel.py delete mode 100644 tests/test_infosets.py delete mode 100644 tests/test_node.py create mode 100644 tests/test_outcome_mutations.py create mode 100644 tests/test_outcome_queries.py delete mode 100644 tests/test_outcomes.py create mode 100644 tests/test_profile_invalidation.py rename tests/{test_strategic.py => test_strategic_mutations.py} (79%) create mode 100644 tests/test_strategic_queries.py create mode 100644 tests/test_tree_mutations.py create mode 100644 tests/test_tree_queries.py diff --git a/.github/workflows/osxbinary.yml b/.github/workflows/osxbinary.yml index bf823dc0f9..343f2dd31c 100644 --- a/.github/workflows/osxbinary.yml +++ b/.github/workflows/osxbinary.yml @@ -32,7 +32,12 @@ jobs: - run: make - run: sudo make install - run: make osx-dmg + - run: make dist - uses: actions/upload-artifact@v7 with: name: artifact-osx-14 path: "*.dmg" + - uses: actions/upload-artifact@v7 + with: + name: artifact-source-dist + path: "*.tar.gz" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fc6e79b78f..c7232128c8 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -31,3 +31,24 @@ jobs: - uses: actions/upload-artifact@v7 with: path: ./wheelhouse/*.whl + + sdist: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.x' + - name: Set up dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build sdist + run: | + python -m build --sdist + - uses: actions/upload-artifact@v7 + with: + name: sdist + path: ./dist/*.tar.gz diff --git a/ChangeLog b/ChangeLog index 4a9d60f97a..843ce768cc 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,38 @@ +## [17.0.0-beta.1] - unreleased + +### Added +- Added `Game.has_perfect_recall(player)`, and `GameRep::HasPerfectRecall(const GamePlayer &)` + in C++, reporting whether an individual player has perfect recall. (#1107) +- Added `Game.get_outcomes()`, returning a materialized list of outcome labels, replacing + `Game.outcomes`. +- Added `Game.get_outcome_payoffs(label)`, returning the payoff to each player at the outcome + labeled `label`. +- Added `Game.set_outcome_payoffs(label, payoffs)`, setting the payoffs at the outcome labeled + `label`; `payoffs` must be a complete mapping over the game's players, as with + `Game.make_outcome`. +- Added `Game.relabel_outcomes(labels, strict=True)`, simultaneously reassigning the labels of + the game's outcomes, following the same pattern as `Game.relabel_players`/ + `Game.relabel_strategies`. Added `GameRep::RelabelOutcomes` in C++. + +### Changed +- `Game.make_outcome` now returns `None` instead of the `Outcome` created; use the `label` + already passed in, together with `Game.get_outcome_payoffs`/`Game.relabel_outcomes`, to refer + to it afterward. + +### Removed +- `Outcome` has been removed as a public-facing class in pygambit, following `Action`, + `Infoset`, `Player`, `Strategy`, and `Node`: no public method holds or returns a live handle + into a game's internal outcome list any longer. `Game.outcomes` has been removed; use + `Game.get_outcomes()`, `Game.get_outcome_payoffs()`, `Game.set_outcome_payoffs()`, and + `Game.relabel_outcomes()` instead. + +### Fixed +- `GameTableRep`'s constructor (used by `Game.from_arrays`/`Game.from_dict`, which build a + non-sparse table with one outcome per contingency) gave every outcome the same empty label, + violating the invariant that every non-null outcome has a unique, nonempty label. Outcomes + are now labeled `"1"`, `"2"`, and so on, following the existing convention for default + player/strategy labels. + ## [17.0.0-alpha.3] - 2026-08-31 ### Added diff --git a/doc/algorithms.rst b/doc/algorithms.rst index 4238a15559..317306b029 100644 --- a/doc/algorithms.rst +++ b/doc/algorithms.rst @@ -15,10 +15,11 @@ Algorithm Description :ref:`lp` Compute equilibria in a two-player constant-sum game via linear programming :py:func:`pygambit.nash.lp_solve` :ref:`gambit-lp ` :ref:`lcp` Compute equilibria in a two-player game via linear complementarity :py:func:`pygambit.nash.lcp_solve` :ref:`gambit-lcp ` :ref:`liap` Compute equilibria using function minimization :py:func:`pygambit.nash.liap_solve` :ref:`gambit-liap ` -:ref:`logit` Trace logit QRE and approximate a Nash equilibrium at high precision :py:func:`pygambit.nash.logit_solve` :ref:`gambit-logit ` +:ref:`logit` Trace logit QRE and approximate a Nash equilibrium at high precision :py:func:`pygambit.nash.logit_solve` :ref:`gambit-logit ` :ref:`simpdiv` Compute equilibria via simplicial subdivision :py:func:`pygambit.nash.simpdiv_solve` :ref:`gambit-simpdiv ` :ref:`ipa` Compute equilibria using iterated polymatrix approximation :py:func:`pygambit.nash.ipa_solve` :ref:`gambit-ipa ` :ref:`gnm` Compute equilibria using a global Newton method :py:func:`pygambit.nash.gnm_solve` :ref:`gambit-gnm ` +:ref:`hp` Compute a specific Nash equilibrium using a homotopy path-following method :py:func:`pygambit.nash.hp_solve` :ref:`gambit-hp ` ================ =========================================================================== ======================================== ========================================== .. _enumpure: @@ -234,3 +235,20 @@ The algorithm takes as a parameter a mixed strategy profile. This profile is interpreted as defining a ray in the space of games. The profile must have the property that, for each player, the most frequently played strategy must be unique. + +.. _hp: + +hp +--- +Computes the Nash equilibrium selected by the tracing procedure +of Harsanyi and Selten using a homotopy path-following method. The algorithm +was first described by Herings and Peeters :cite:p:`HerPee01`. + +The algorithm takes as a parameter a mixed strategy profile, which acts as +the subjective prior beliefs of the players. +The profile must have the property that, for each player, +there must only exist one best response. + +For generic games, the algorithm converges to the unique Nash equilibrium selected by +the tracing procedure of Harsanyi and Selten. For non-generic games, the algorithm may +converge to a Nash equilibrium that is not selected by the tracing procedure. diff --git a/doc/gui.nash.rst b/doc/gui.nash.rst index 2df8238e4b..5a07164af0 100644 --- a/doc/gui.nash.rst +++ b/doc/gui.nash.rst @@ -87,6 +87,7 @@ Method Parameters used by the graphical interface ``ipa`` One random perturbation. ``gnm`` One random perturbation; ending lambda ``-10``; 100 steps per support cell; local Newton refinement every 3 steps, with at most 10 iterations. +``hp`` No method-specific parameters. ================ ============================================================================ For extensive games, there is an option of whether to use the diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 67b286597d..cd7da0de3c 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -14,12 +14,9 @@ Representation of games :toctree: api/ Game - Outcome Node - Infoset - Event - Branch - Subgame + TreeLayout + TreeLayoutCoordinates Creating, reading, and writing games @@ -45,6 +42,15 @@ Creating, reading, and writing games Game.to_latex +Computing a tree layout for graphical display +.............................................. + +.. autosummary:: + :toctree: api/ + + layout_tree + + Transforming game trees ....................... @@ -74,7 +80,6 @@ Transforming game information structure Game.relabel_actions Game.set_move_actions Game.set_event_actions - Game.reveal Transforming game components @@ -89,6 +94,8 @@ Transforming game components Game.set_strategies Game.make_outcome Game.make_outcome_null + Game.relabel_outcomes + Game.set_outcome_payoffs Information about the game @@ -102,86 +109,32 @@ Information about the game Game.is_const_sum Game.is_tree Game.is_perfect_recall + Game.has_perfect_recall Game.players - Game.outcomes Game.min_payoff Game.max_payoff Game.get_min_payoff Game.get_max_payoff - Game.root Game.get_infosets Game.get_events Game.get_strategies Game.get_sequences - Game.nodes Game.contingencies Game.get_outcome + Game.get_outcomes + Game.get_outcome_payoffs Game.get_payoffs - Game.subgames - Game.minimal_subgame + Game.get_subgame_roots + Game.get_minimal_subgame + Game.get_strategy_unreachable .. autosummary:: :toctree: api/ - Outcome.label - Outcome.number - Outcome.game - -.. autosummary:: - :toctree: api/ - - Node.label - Node.game - Node.outcome - Node.children - Node.parent - Node.is_subgame_root - Node.is_terminal - Node.is_strategy_reachable - Node.prior_action - Node.prior_sibling - Node.next_sibling - Node.infoset - Node.event Node.members Node.actions Node.action_probs Node.player - Node.is_successor_of - Node.plays - Node.own_prior_action - -.. autosummary:: - :toctree: api/ - - Subgame.game - Subgame.root - Subgame.parent - Subgame.children - -.. autosummary:: - - :toctree: api/ - - Infoset.label - Infoset.game - Infoset.is_absent_minded - Infoset.player - Infoset.actions - Infoset.members - Infoset.precedes - -.. autosummary:: - - :toctree: api/ - - Event.label - Event.game - Event.is_absent_minded - Event.player - Event.actions - Event.members - Event.precedes .. autosummary:: @@ -290,7 +243,6 @@ Probability distributions over behavior MixedBehaviorProfile.realiz_probs MixedBehaviorProfile.infoset_probs MixedBehaviorProfile.beliefs - MixedBehaviorProfile.is_defined_at MixedBehaviorProfile.agent_max_regret MixedBehaviorProfile.agent_liap_value MixedBehaviorProfile.max_regret @@ -358,7 +310,7 @@ Subsets of actions BehaviorSupportProfile.__getitem__ BehaviorSupportProfile.__setitem__ BehaviorSupportProfile.copy - BehaviorSupportProfile.is_reachable + BehaviorSupportProfile.is_infoset_reachable BehaviorSupport BehaviorSupport.player @@ -366,7 +318,7 @@ Subsets of actions BehaviorSupport.__getitem__ ActionSupport - ActionSupport.infoset + ActionSupport.history ActionSupport.__iter__ ActionSupport.__contains__ diff --git a/doc/tools.hp.rst b/doc/tools.hp.rst new file mode 100644 index 0000000000..b2eac367c4 --- /dev/null +++ b/doc/tools.hp.rst @@ -0,0 +1,76 @@ +.. _gambit-hp: + +:program:`gambit-hp` +===================== + +Compute a Nash equilibrium in a strategic game using the homotopy method of +:cite:t:`HerPee01`. + +The algorithm finds one equilibrium starting from any given prior +distribution over strategies, which must have a unique best response for +each player. Multiple prior distributions may be generated via the `-n` +option or specified via the `-s` option; different priors may result in +different equilibria being found. + + +.. program:: gambit-hp + +.. cmdoption:: -d + + Express all output using decimal representations + with the specified number of digits. + +.. cmdoption:: -h + + Prints a help message listing the available options. + +.. cmdoption:: -n + + Randomly generate the specified number of prior distributions. + Mutually exclusive with :option:`-s`. + +.. cmdoption:: -R + + Seeds the random number generator used to generate prior + distributions with the specified value, so that the sequence of priors + generated by :option:`-n` can be reproduced across runs. If not + specified, the generator is seeded from system entropy. Requires + :option:`-n`. + +.. cmdoption:: -q + + Suppresses printing of the banner at program launch. + +.. cmdoption:: -s + + Specifies a file containing a list of prior distributions over + strategies. The format of the file is comma-separated values, + one mixed strategy profile per line, in the same format used for + output of equilibria (excluding the initial NE tag). + Mutually exclusive with :option:`-n`. + +.. cmdoption:: -V, --verbose + + Show the prior distribution itself, tagged `prior`, followed by each + point traced along the homotopy path, tagged with the homotopy + parameter t in place of the NE tag. Note that the point at t=0 is the + best response to the prior, and so is generally a pure strategy profile + even when the prior itself is not. If this option is not specified, + only the equilibrium found is reported. + +.. cmdoption:: -v, --version + + Prints version information and exits. + + +Computing an equilibrium of the reduced strategic form of the example in +Figure 2 of :cite:p:`Sel75`, starting from the prior in which player 1 +plays (0.5, 0.3, 0.2) and player 2 plays (0.6, 0.4):: + + $ echo "0.5,0.3,0.2,0.6,0.4" > prior.csv + $ gambit-hp -s prior.csv catalog/journals/ijgt/selten1975/fig2.efg + Compute a Nash equilibrium using the Herings-Peeters (2001) homotopy method + Gambit version |release|, Copyright (C) 1994-2026, The Gambit Project + This is free software, distributed under the GNU GPL + + NE,1.000000,0.000000,0.000000,0.999976,0.000024 diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 97ae7c99ad..dd3a687cdf 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -93,7 +93,7 @@ "id": "962b4e52", "metadata": {}, "source": [ - "To extend a game from an existing terminal node, use `Game.append_move`. To begin with, the sole root node is the terminal node.\n", + "To extend a game from an existing terminal node, use `Game.append_move`. `append_move` takes an `H`-built selector identifying the node(s) to add the move at, rather than a `Node` object directly; `gbt.H.path()` (with no arguments) selects the root itself, which to begin with is the sole terminal node.\n", "\n", "Here we extend the game from the root node by adding the first move for the \"Buyer\" player, creating two child nodes (one for each possible action)." ] @@ -106,7 +106,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " g.root, # This is the node to append the move to\n", + " gbt.H.path(), # Selects the root node\n", " player=\"Buyer\",\n", " actions=[\"Trust\", \"Not trust\"]\n", ")" @@ -138,7 +138,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " g.root.children[\"Trust\"],\n", + " gbt.H.path(\"Trust\"),\n", " player=\"Seller\",\n", " actions=[\"Honor\", \"Abuse\"]\n", ")" @@ -158,13 +158,7 @@ "cell_type": "markdown", "id": "382ba37d", "metadata": {}, - "source": [ - "Now that we have the moves of the game defined, we add payoffs.\n", - "\n", - "Payoffs are associated with an `Outcome`; each `Outcome` has a vector of payoffs, one for each player, and optionally an identifying text label.\n", - "\n", - "First we add the outcome associated with the Seller proving themselves trustworthy:" - ] + "source": "Now that we have the moves of the game defined, we add payoffs.\n\nPayoffs are associated with an outcome, identified by a text label; each outcome has a vector of payoffs, one for each player.\n\nFirst we add the outcome associated with the Seller proving themselves trustworthy:" }, { "cell_type": "code", @@ -172,7 +166,13 @@ "id": "716e9b9a", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Honor\"],\n {\"Buyer\": 1, \"Seller\": 1},\n \"Trustworthy\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Trust\", \"Honor\"),\n", + " {\"Buyer\": 1, \"Seller\": 1},\n", + " \"Trustworthy\"\n", + ")" + ] }, { "cell_type": "code", @@ -198,7 +198,13 @@ "id": "695b1aad", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Abuse\"],\n {\"Buyer\": -1, \"Seller\": 2},\n \"Untrustworthy\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Trust\", \"Abuse\"),\n", + " {\"Buyer\": -1, \"Seller\": 2},\n", + " \"Untrustworthy\"\n", + ")" + ] }, { "cell_type": "code", @@ -224,7 +230,13 @@ "id": "0704ef86", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Not trust\"],\n {\"Buyer\": 0, \"Seller\": 0},\n \"Opt-out\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Not trust\"),\n", + " {\"Buyer\": 0, \"Seller\": 0},\n", + " \"Opt-out\"\n", + ")" + ] }, { "cell_type": "code", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 0c3b3c81fd..fab307ac26 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -96,7 +96,13 @@ "cell_type": "markdown", "id": "0d4c7f5b", "metadata": {}, - "source": "A move belonging to the chance player is called an **event**, and is created with `append_event` rather than `append_move`, since it requires the probability distribution over its actions to be specified explicitly.\n\nThe first step in this game is that Alice is dealt a card which could be a King or Queen, each with probability 1/2.\n\nTo simulate this in Gambit, we create a chance event at the root node of the game:" + "source": [ + "A move belonging to the chance player is called an **event**, and is created with `append_event` rather than `append_move`, since it requires the probability distribution over its actions to be specified explicitly. Like `append_move`, `append_event` takes an `H`-built selector identifying the node(s) to add the event at.\n", + "\n", + "The first step in this game is that Alice is dealt a card which could be a King or Queen, each with probability 1/2.\n", + "\n", + "To simulate this in Gambit, we create a chance event at the root node of the game, using `gbt.H.path()` to select it:" + ] }, { "cell_type": "code", @@ -104,7 +110,7 @@ "id": "fe80c64c", "metadata": {}, "outputs": [], - "source": "g.append_event(\n g.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)" + "source": "g.append_event(\n gbt.H.path(),\n actions={\"King\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)" }, { "cell_type": "code", @@ -126,8 +132,8 @@ "In this game, information structure is important.\n", "Alice knows her card, so the two nodes at which she has the move are part of different **information sets**.\n", "\n", - "We'll therefore need to append Alice's move separately for each of the root node's children, i.e. the scenarios where she has a King or a Queen.\n", - "Let's now add both of these possible moves:" + "We'll therefore need to append Alice's move separately for each possible card, i.e. the scenarios where she has a King or a Queen.\n", + "`append_move` takes an `H`-built selector describing which node(s) to add the move at; `gbt.H.path(label)` describes the node reached by taking the action labeled `label` from the root:" ] }, { @@ -137,12 +143,8 @@ "metadata": {}, "outputs": [], "source": [ - "for node in g.root.children:\n", - " g.append_move(\n", - " node,\n", - " player=\"Alice\",\n", - " actions=[\"Bet\", \"Fold\"]\n", - " )" + "for card in [\"King\", \"Queen\"]:\n", + " g.append_move(gbt.H.path(card), player=\"Alice\", actions=[\"Bet\", \"Fold\"])" ] }, { @@ -164,13 +166,13 @@ "\n", "In contrast, Bob does not know Alice’s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", "\n", - " - Chance player chooses King, then Alice Bets: `g.root.children[\"King\"].children[\"Bet\"]`\n", - " - Chance player chooses Queen, then Alice Bets: `g.root.children[\"Queen\"].children[\"Bet\"]`\n", + " - Chance player chooses King, then Alice Bets: `gbt.H.path(\"King\", \"Bet\")`\n", + " - Chance player chooses Queen, then Alice Bets: `gbt.H.path(\"Queen\", \"Bet\")`\n", "\n", "In other words, Bob's decision when Alice Bets with a Queen should be part of the same information set as Bob's decision when Alice Bets with a King.\n", "\n", - "To set this scenario up in Gambit, we'll need to add both possible moves as part of the same information set (represented in Gambit as an `Infoset`).\n", - "This can be done by passing a list of nodes to the `append_move` method:" + "To set this scenario up in Gambit, we'll need to add both possible moves as part of the same information set.\n", + "This can be done with a single selector: `gbt.H.path(..., \"Bet\")` describes the node reached by *any* single action from the root (either card), followed by \"Bet\" -- so it matches both of Bob's decision nodes at once, joining them into one information set:" ] }, { @@ -181,7 +183,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " [g.root.children[\"King\"].children[\"Bet\"], g.root.children[\"Queen\"].children[\"Bet\"]],\n", + " gbt.H.path(..., \"Bet\"),\n", " player=\"Bob\",\n", " actions=[\"Call\", \"Fold\"]\n", ")" @@ -209,7 +211,35 @@ "id": "29aa60a0", "metadata": {}, "outputs": [], - "source": "# Alice folds, Bob wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Fold\"], g.root.children[\"Queen\"].children[\"Fold\"]],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ng.make_outcome(\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ng.make_outcome(\n g.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" + "source": [ + "# Alice folds, Bob wins small\n", + "g.make_outcome(\n", + " gbt.H.path(..., \"Fold\"),\n", + " {\"Alice\": -1, \"Bob\": 1},\n", + " \"Lose\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\n", + "g.make_outcome(\n", + " gbt.H.path(\"Queen\", \"Bet\", \"Call\"),\n", + " {\"Alice\": -2, \"Bob\": 2},\n", + " \"Lose Big\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\n", + "g.make_outcome(\n", + " gbt.H.path(\"King\", \"Bet\", \"Call\"),\n", + " {\"Alice\": 2, \"Bob\": -2},\n", + " \"Win Big\"\n", + ")\n", + "\n", + "# Bob does not call Alice's Bet, Alice wins small\n", + "g.make_outcome(\n", + " gbt.H.path(..., \"Bet\", \"Fold\"),\n", + " {\"Alice\": 1, \"Bob\": -1},\n", + " \"Win\"\n", + ")" + ] }, { "cell_type": "code", @@ -326,7 +356,7 @@ "id": "f45a82b6", "metadata": {}, "outputs": [], - "source": "for infoset, mixed_action in eqm[\"Alice\"]:\n print(\n f\"At information set {infoset.number}, \"\n f\"Alice plays Bet with probability: {mixed_action['Bet']}\"\n f\" and Fold with probability: {mixed_action['Fold']}\"\n )" + "source": "for number, (_infoset, mixed_action) in enumerate(eqm[\"Alice\"], start=1):\n print(\n f\"At information set {number}, \"\n f\"Alice plays Bet with probability: {mixed_action['Bet']}\"\n f\" and Fold with probability: {mixed_action['Fold']}\"\n )" }, { "cell_type": "markdown", @@ -336,6 +366,14 @@ "We can alternatively iterate through each of a player's actions like so:" ] }, + { + "cell_type": "markdown", + "id": "9c1f6a2e", + "metadata": {}, + "source": [ + "`Game.get_infosets` returns each information set's canonical member as a `History` -- a plain tuple of action labels from the root. Indexing the profile directly by that tuple requires turning it into a `Selector` first, since `MixedBehaviorProfile.__getitem__` accepts only a player label or a `Selector`:" + ] + }, { "cell_type": "code", "execution_count": null, @@ -343,11 +381,11 @@ "metadata": {}, "outputs": [], "source": [ - "for node in g.get_infosets(\"Alice\"):\n", - " for action in node.actions:\n", + "for number, node in enumerate(g.get_infosets(\"Alice\"), start=1):\n", + " for action in g.get_actions(gbt.H.path(*node)):\n", " print(\n", - " f\"At information set {node.infoset.number}, \"\n", - " f\"Alice plays {action} with probability: {eqm[node][action]}\"\n", + " f\"At information set {number}, \"\n", + " f\"Alice plays {action} with probability: {eqm[gbt.H.path(*node)][action]}\"\n", " )" ] }, @@ -386,7 +424,10 @@ "id": "2966e700", "metadata": {}, "outputs": [], - "source": "(bob_node,) = g.get_infosets(\"Bob\")\nbob_infoset = bob_node.infoset\neqm[bob_node][\"Call\"]" + "source": [ + "(bob_node,) = g.get_infosets(\"Bob\")\n", + "eqm[gbt.H.path(*bob_node)][\"Call\"]" + ] }, { "cell_type": "markdown", @@ -405,8 +446,8 @@ "metadata": {}, "outputs": [], "source": [ - "bob_action_values = eqm.action_values[bob_node]\n", - "for action in bob_infoset.actions:\n", + "bob_action_values = eqm.action_values[gbt.H.path(*bob_node)]\n", + "for action in g.get_actions(gbt.H.path(*bob_node)):\n", " print(\n", " f\"When Bob plays {action} his expected payoff is {bob_action_values[action]}\"\n", " )" @@ -421,7 +462,7 @@ "\n", "`MixedBehaviorProfile.beliefs` returns the probability of reaching each node, conditional on its information set being reached.\n", "\n", - "Recall that the two nodes in Bob's only information set are `g.root.children[\"King\"].children[\"Bet\"]` and `g.root.children[\"Queen\"].children[\"Bet\"]`):" + "Recall that the two nodes in Bob's only information set are `(\"King\", \"Bet\")` and `(\"Queen\", \"Bet\")` (as Histories):" ] }, { @@ -431,10 +472,10 @@ "metadata": {}, "outputs": [], "source": [ - "for node in bob_infoset.members:\n", + "for node in g.get_members(gbt.H.path(*bob_node)):\n", " print(\n", - " f\"Bob's belief in reaching the {node.parent.prior_action.label} -> \"\n", - " f\"{node.prior_action.label} node is: {eqm.beliefs[node]}\"\n", + " f\"Bob's belief in reaching the {node[-2]} -> \"\n", + " f\"{node[-1]} node is: {eqm.beliefs[node]}\"\n", " )" ] }, @@ -455,7 +496,7 @@ "metadata": {}, "outputs": [], "source": [ - "eqm.infoset_probs[bob_node]" + "eqm.infoset_probs[gbt.H.path(*bob_node)]" ] }, { @@ -474,10 +515,10 @@ "outputs": [], "source": [ "bob_node_values = eqm.node_values[\"Bob\"]\n", - "for node in bob_infoset.members:\n", + "for node in g.get_members(gbt.H.path(*bob_node)):\n", " print(\n", - " f\"The probability that the node {node.parent.prior_action.label} -> \"\n", - " f\"{node.prior_action.label} is reached is: {eqm.realiz_probs[node]}. \",\n", + " f\"The probability that the node {node[-2]} -> \"\n", + " f\"{node[-1]} is reached is: {eqm.realiz_probs[node]}. \",\n", " f\"Bob's expected payoff conditional on reaching this node is {bob_node_values[node]}\"\n", " )" ] @@ -612,7 +653,23 @@ "id": "d18a91f0", "metadata": {}, "outputs": [], - "source": "for player in g.players:\n print(\n f\"{player}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for node in g.get_infosets(player):\n for action in node.actions:\n print(\n f\"At information set {node.infoset.number}, \"\n f\"when playing {action} - \"\n f\"gnm: {gnm_action_values[node][action]:.4f}\"\n f\", lcp: {str(lcp_action_values[node][action])}\"\n )\n print()" + "source": [ + "for player in g.players:\n", + " print(\n", + " f\"{player}'s expected payoffs:\"\n", + " )\n", + " gnm_action_values = gnm_eqm.as_behavior().action_values\n", + " lcp_action_values = eqm.action_values\n", + " for number, node in enumerate(g.get_infosets(player), start=1):\n", + " for action in g.get_actions(gbt.H.path(*node)):\n", + " print(\n", + " f\"At information set {number}, \"\n", + " f\"when playing {action} - \"\n", + " f\"gnm: {gnm_action_values[gbt.H.path(*node)][action]:.4f}\"\n", + " f\", lcp: {str(lcp_action_values[gbt.H.path(*node)][action])}\"\n", + " )\n", + " print()" + ] }, { "cell_type": "markdown", @@ -794,16 +851,7 @@ "id": "a892dc2b", "metadata": {}, "outputs": [], - "source": [ - "for outcome in g.outcomes:\n", - " outcome[\"Alice\"] = outcome[\"Alice\"] * 2\n", - " outcome[\"Bob\"] = outcome[\"Bob\"] * 2\n", - "\n", - "(\n", - " gbt.nash.liap_solve(g.mixed_strategy_profile(), maxregret=1e-1)\n", - " .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n", - ")" - ] + "source": "for label in g.get_outcomes():\n payoffs = g.get_outcome_payoffs(label)\n g.set_outcome_payoffs(label, {player: value * 2 for player, value in payoffs})\n\n(\n gbt.nash.liap_solve(g.mixed_strategy_profile(), maxregret=1e-1)\n .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n)" }, { "cell_type": "markdown", @@ -819,8 +867,8 @@ "outputs": [], "source": [ "small_game = gbt.Game.new_tree()\n", - "small_game.append_event(small_game.root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\n", - "list(small_game.root.action_probs.values())" + "small_game.append_event(gbt.H.path(), dict.fromkeys([\"a\", \"b\", \"c\"], gbt.Rational(1, 3)))\n", + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -837,10 +885,10 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", - " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", + " gbt.H.path(),\n", + " {\"a\": gbt.Rational(1, 4), \"b\": gbt.Rational(1, 2), \"c\": gbt.Rational(1, 4)}\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -859,10 +907,10 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", - " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", + " gbt.H.path(),\n", + " {\"a\": gbt.Decimal(\".25\"), \"b\": gbt.Decimal(\".50\"), \"c\": gbt.Decimal(\".25\")}\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -884,8 +932,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event(gbt.H.path(), {\"a\": \"1/4\", \"b\": \"1/2\", \"c\": \"1/4\"})\n", + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -895,8 +943,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event(gbt.H.path(), {\"a\": \".25\", \"b\": \".50\", \"c\": \".25\"})\n", + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -919,8 +967,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [.25, .50, .25])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event(gbt.H.path(), {\"a\": .25, \"b\": .50, \"c\": .25})\n", + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -937,7 +985,12 @@ "id": "1991d288", "metadata": {}, "outputs": [], - "source": "try:\n small_game.make_event([small_game.root], [1/3, 1/3, 1/3])\nexcept ValueError as e:\n print(\"ValueError:\", e)\n" + "source": [ + "try:\n", + " small_game.make_event(gbt.H.path(), {\"a\": 1/3, \"b\": 1/3, \"c\": 1/3})\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" + ] }, { "cell_type": "markdown", diff --git a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb index cee1fec173..0785585928 100644 --- a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb +++ b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb @@ -84,7 +84,7 @@ "id": "6e3e9303-453a-4bac-a449-fa8fda2ba5ec", "metadata": {}, "outputs": [], - "source": "eq = pure_Nash_equilibria[0]\nfor behavior in eq.as_behavior():\n for infoset, probs in behavior:\n print(infoset.player, \"infoset:\", infoset.number, \"behavior probabilities:\", probs)" + "source": "eq = pure_Nash_equilibria[0]\nfor behavior in eq.as_behavior():\n for number, (_infoset, probs) in enumerate(behavior, start=1):\n print(behavior.player, \"infoset:\", number, \"behavior probabilities:\", probs)" }, { "cell_type": "markdown", diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb new file mode 100644 index 0000000000..1303f0dad6 --- /dev/null +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -0,0 +1,454 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "014efe26", + "metadata": {}, + "source": [ + "# The `H` node-selector algebra: six worked examples\n", + "\n", + "This notebook is a design prototype, not a released feature. `pygambit.gambit.H` is an\n", + "internal, unreleased module -- everything here demonstrates a work-in-progress replacement\n", + "for constructing extensive-form games without ever handling raw `Node` objects.\n", + "\n", + "The core idea: a *selector*, built from `H`, describes a set of histories symbolically --\n", + "it carries no reference to any particular game until you hand it to one. `H.path(*steps)`\n", + "walks a sequence of exact labels and/or `...` wildcards from the root (or from wherever a\n", + "selection currently is, when chained); `H.after(*labels)` matches anywhere by a trailing\n", + "label pattern; `.plays` expands to whatever is currently terminal; `.by(callable)`\n", + "partitions a selection by a key function, and `.filter(callable)` keeps only matching\n", + "elements. `Game.append_move`/`append_event`/`append_infoset`/`make_outcome` all accept these\n", + "selectors directly, in place of `Node`/`NodeReferenceSet`.\n", + "\n", + "Six examples below, each chosen to exercise a different corner of the design: a classic\n", + "imperfect-information game needing `append_infoset`, a game with betting and outcome\n", + "computation, a regular two-stage Bayesian game, three different shapes of imperfect\n", + "*recall*, and a variation showing `append_infoset` composes normally with further\n", + "construction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e80dd03", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:36.060080Z", + "iopub.status.busy": "2026-09-02T18:45:36.059915Z", + "iopub.status.idle": "2026-09-02T18:45:36.970529Z", + "shell.execute_reply": "2026-09-02T18:45:36.970258Z" + } + }, + "outputs": [], + "source": [ + "try:\n", + " from gtdraw import draw\n", + "except ImportError:\n", + " def draw(*args, **kwargs):\n", + " print(\"gtdraw is not installed; game trees won't be drawn, but everything else runs.\")\n", + "\n", + "from pygambit.gambit import H\n", + "\n", + "import pygambit as gbt\n", + "\n", + "\n", + "def selector_for_histories(histories):\n", + " \"\"\"A Selector matching exactly the given (already-materialized) Histories --\n", + " for passing a `_get_groups` group to a mutation method, which only accepts a\n", + " Selector/GroupedSelector, not a bare iterable of History tuples.\"\"\"\n", + " keys = frozenset(histories)\n", + " return H.after().filter(lambda h: h[:] in keys)" + ] + }, + { + "cell_type": "markdown", + "id": "ddd44d44", + "metadata": {}, + "source": [ + "## 1. Selten's Horse\n", + "\n", + "A classic three-player game (Selten, 1975) used to illustrate subtleties of sequential\n", + "equilibrium. Player 1 moves first; if he plays \"R\", Player 2 moves; if Player 2 also plays\n", + "\"L\", or if Player 1 played \"L\" directly, Player 3 faces the same decision either way --\n", + "**Player 3 cannot tell which path led there**.\n", + "\n", + "This needs `append_infoset`, not because of anything exotic about recall or timing (the\n", + "game is perfectly ordinary on both counts), but for a mundane construction-ordering reason:\n", + "Player 3's two infoset members aren't simultaneously available. The node reached via a bare\n", + "\"L\" exists as soon as Player 1 moves; the node reached via \"R\", \"L\" only exists once Player 2\n", + "has *also* moved -- so one `append_move` call can never cover both." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "25387762", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:36.971889Z", + "iopub.status.busy": "2026-09-02T18:45:36.971789Z", + "iopub.status.idle": "2026-09-02T18:45:37.473789Z", + "shell.execute_reply": "2026-09-02T18:45:37.473536Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n", + "Player 3's infoset members (as Histories, not raw Node paths -- the latter display node-to-root, easy to misread): [('L',), ('R', 'L')]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\", \"Player 3\"], title=\"Selten's Horse\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"L\"), \"Player 3\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"R\"), \"Player 2\", [\"R\", \"L\"])\n", + "g.append_infoset(H.path(\"R\", \"L\"), H.path(\"L\"))\n", + "\n", + "g.make_outcome(H.path(\"R\", \"R\"), {\"Player 1\": 1, \"Player 2\": 1, \"Player 3\": 1}, \"RR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"R\"), {\"Player 1\": 4, \"Player 2\": 4, \"Player 3\": 0}, \"RLR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 1}, \"RLL\")\n", + "g.make_outcome(H.path(\"L\", \"R\"), {\"Player 1\": 3, \"Player 2\": 2, \"Player 3\": 2}, \"LR\")\n", + "g.make_outcome(H.path(\"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 0}, \"LL\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 3's infoset members (as Histories, not raw Node paths -- the latter\"\n", + " \" display node-to-root, easy to misread):\",\n", + " sorted(g._get_histories(H.path(\"L\")) + g._get_histories(H.path(\"R\", \"L\"))),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2ca9c9c1", + "metadata": {}, + "source": [ + "## 2. Kuhn poker\n", + "\n", + "Three-card poker, the standard small illustration of imperfect information *and* betting.\n", + "This example exercises the recall-tracking machinery in earnest: Alice's second decision\n", + "(call/fold after checking then facing a bet) must still distinguish her own card, even\n", + "though the tree has grown well past where that distinction was first established.\n", + "\n", + "`alice_partition` is built once, tagged `.with_recall(\"Alice\")`, and reused for both of her\n", + "decisions -- the tag makes `.plays` automatically fold her own last action into the group\n", + "key from her second decision onward, with no separate re-derivation step. `bob_partition`\n", + "never needs the tag: his two uses are his *one* decision instantiated on two mutually\n", + "exclusive branches, not a first-then-second sequence for him.\n", + "\n", + "Outcome computation is a genuinely different kind of selector from the recall-tracking\n", + "above: `winner`/`pot_size` are direct, declarative facts about a completed hand (who took\n", + "the pot, how much), not a player's own partial view of the game -- an outcome deliberately\n", + "throws away *how* a given payoff was reached, which is the opposite spirit from recall\n", + "grouping's insistence on never conflating what a player can actually tell apart." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "923feb0b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:37.474927Z", + "iopub.status.busy": "2026-09-02T18:45:37.474792Z", + "iopub.status.idle": "2026-09-02T18:45:38.086934Z", + "shell.execute_reply": "2026-09-02T18:45:38.086660Z" + } + }, + "outputs": [], + "source": [ + "CARD_VALUE = {\"J\": 0, \"Q\": 1, \"K\": 2}\n", + "cards = list(CARD_VALUE)\n", + "\n", + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Kuhn poker\")\n", + "g.append_event(H.path(), dict.fromkeys(cards, gbt.Rational(1, 3)))\n", + "for c in cards:\n", + " remaining = [x for x in cards if x != c]\n", + " g.append_event(H.path(c), dict.fromkeys(remaining, gbt.Rational(1, 2)))\n", + "\n", + "alice_partition = H.path(...).by(lambda h: h[0]).with_recall(\"Alice\")\n", + "g.append_move(alice_partition.plays, \"Alice\", [\"Check\", \"Bet\"])\n", + "\n", + "bob_partition = H.path(..., ...).by(lambda h: h[1])\n", + "g.append_move(bob_partition.plays.after(\"Check\"), \"Bob\", [\"Check\", \"Bet\"])\n", + "\n", + "g.append_move(alice_partition.plays.after(\"Check\", \"Bet\"), \"Alice\", [\"Fold\", \"Call\"])\n", + "g.append_move(bob_partition.plays.after(\"Bet\"), \"Bob\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b11e3f16", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.088103Z", + "iopub.status.busy": "2026-09-02T18:45:38.087990Z", + "iopub.status.idle": "2026-09-02T18:45:38.091896Z", + "shell.execute_reply": "2026-09-02T18:45:38.091662Z" + } + }, + "outputs": [], + "source": "def winner(h):\n match h[2:]:\n case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n return \"Alice\" if CARD_VALUE[h[0]] > CARD_VALUE[h[1]] else \"Bob\"\n case (\"Check\", \"Bet\", \"Fold\"):\n return \"Bob\"\n case (\"Bet\", \"Fold\"):\n return \"Alice\"\n\ndef pot_size(h):\n match h[2:]:\n case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Fold\") | (\"Bet\", \"Fold\"):\n return 1\n case (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n return 2\n\nfor (win, amount), group in g._get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n g.make_outcome(\n selector_for_histories(group), {win: amount, lose: -amount}, f\"{win} wins {amount}\"\n )\n\nprint(\"Total outcomes created:\", len(g.get_outcomes()))" + }, + { + "cell_type": "markdown", + "id": "51ade76e", + "metadata": {}, + "source": [ + "## 3. `bayes2a`: a regular two-stage Bayesian game\n", + "\n", + "A fully \"timeable\" game with private types and two rounds of simultaneous moves --\n", + "`contrib/games/bayes2a.efg` in the repository. Both players privately learn a type, then\n", + "move simultaneously each round; each round's actions become public before the next round.\n", + "\n", + "Unlike Kuhn poker, this game needs neither `.with_recall` nor `append_infoset` -- every\n", + "player's decision falls at a fixed, predictable position in the history across every\n", + "branch, so plain positional indexing on the augmented history object is all the grouping\n", + "needs. This is deliberately included as a contrast case: `H`'s dedicated recall machinery\n", + "exists for games that need it, but a well-behaved regular game doesn't have to pay for it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "03767b4c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.092996Z", + "iopub.status.busy": "2026-09-02T18:45:38.092929Z", + "iopub.status.idle": "2026-09-02T18:45:38.096810Z", + "shell.execute_reply": "2026-09-02T18:45:38.096572Z" + } + }, + "outputs": [], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"bayes2a\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), {\"1G\": half, \"1B\": half})\n", + "for t1 in [\"1G\", \"1B\"]:\n", + " g.append_event(H.path(t1), {\"2g\": half, \"2b\": half})\n", + "\n", + "# Round 1: each player's move depends only on their own type.\n", + "g.append_move(H.path(...).plays.by(lambda h: h[0]), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.path(..., ...).plays.by(lambda h: h[1]), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "# Round 2: both round-1 actions are now public; each player also still knows their own type.\n", + "g.append_move(H.plays.by(lambda h: (h[0], h[2], h[3])), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2], h[3])), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "PAYOFFS = {\n", + " (\"1G\", \"H\", \"h\"): (10, 2), (\"1G\", \"H\", \"l\"): (0, 10),\n", + " (\"1G\", \"L\", \"h\"): (2, 4), (\"1G\", \"L\", \"l\"): (4, 0),\n", + " (\"1B\", \"H\", \"h\"): (4, 2), (\"1B\", \"H\", \"l\"): (2, 10),\n", + " (\"1B\", \"L\", \"h\"): (0, 4), (\"1B\", \"L\", \"l\"): (10, 0),\n", + "}\n", + "for (p1, p2), group in g._get_groups(H.plays.by(lambda h: PAYOFFS[(h[0], h[4], h[5])])).items():\n", + " g.make_outcome(selector_for_histories(group), {\"Player 1\": p1, \"Player 2\": p2}, f\"({p1},{p2})\")\n", + "\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\"terminal histories:\", len(g._get_histories(H.plays)))" + ] + }, + { + "cell_type": "markdown", + "id": "c1af37be", + "metadata": {}, + "source": [ + "## 4. Imperfect recall: forgetting a past observation\n", + "\n", + "A third, distinct shape of imperfect recall, alongside absent-mindedness and\n", + "untimeability below. Alice privately observes a signal (H or L) and acts on it -- her\n", + "first decision is correctly split into two infosets, one per signal. Bob then moves,\n", + "seeing nothing private. Alice's *second* decision is deliberately built to merge across\n", + "both signal values, keyed only by her own first action and Bob's -- she is modeled as\n", + "having forgotten the signal that legitimately informed her own first move.\n", + "\n", + "This is neither absent-mindedness (no single node is ever revisited -- these are two\n", + "separate first-decision infosets being merged, not one node crossed twice) nor\n", + "untimeability (every one of Alice's second-decision nodes sits at exactly the same depth --\n", + "the issue is purely about what she remembers, not about timing)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1fc0b54", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.097844Z", + "iopub.status.busy": "2026-09-02T18:45:38.097770Z", + "iopub.status.idle": "2026-09-02T18:45:38.594007Z", + "shell.execute_reply": "2026-09-02T18:45:38.593205Z" + } + }, + "outputs": [], + "source": [ + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Forgetting a past observation\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), {\"H\": half, \"L\": half})\n", + "g.append_move(H.path(\"H\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(\"L\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(..., ...), \"Bob\", [\"x\", \"y\"])\n", + "\n", + "# Keyed by (Alice's own first action, Bob's action) only -- h[0], the signal, is dropped.\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2])), \"Alice\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "# Depth 3, explicitly -- these are the same histories the construction above\n", + "# grouped by (h[1], h[2]) to create Alice's second decision.\n", + "groups = g._get_groups(H.path(..., ..., ...).by(lambda h: (h[1], h[2])))\n", + "for key, members in sorted(groups.items()):\n", + " print(f\" Alice's 2nd decision, key={key}: {sorted(members)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4d009aae", + "metadata": {}, + "source": [ + "## 5. An untimeable game\n", + "\n", + "Jakobsen, Sørensen & Conitzer (2016), Figure 1(a): a coin toss decides who moves first;\n", + "each player then guesses whether they went first or second, unable to tell which, since\n", + "neither observes the other's move or the coin. Each player's infoset spans both a\n", + "depth-1 node (moving first) and depth-2 nodes (moving second) -- and, unlike Selten's\n", + "Horse above, **no valid timing assignment exists at all**, even allowing a dense\n", + "(non-integer) time scale: each player's second decision would need to come strictly after\n", + "the *other's* first decision, on different branches -- a circular constraint no monotonic\n", + "timing can resolve. Perfect recall holds throughout regardless -- neither player forgets\n", + "anything, each has only one decision to have forgotten at." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d8ac1b78", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.595461Z", + "iopub.status.busy": "2026-09-02T18:45:38.595355Z", + "iopub.status.idle": "2026-09-02T18:45:39.026877Z", + "shell.execute_reply": "2026-09-02T18:45:39.026590Z" + } + }, + "outputs": [], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Untimeable (Jakobsen et al. 2016)\")\n", + "\n", + "g.append_event(H.path(), dict.fromkeys([\"1\", \"2\"], gbt.Rational(1, 2)))\n", + "\n", + "g.append_move(H.path(\"1\"), \"Player 2\", [\"1\", \"2\"])\n", + "g.append_move(H.path(\"2\"), \"Player 1\", [\"1\", \"2\"])\n", + "\n", + "g.append_infoset(H.path(\"1\", ...), H.path(\"2\"))\n", + "g.append_infoset(H.path(\"2\", ...), H.path(\"1\"))\n", + "\n", + "def outcome_key(h):\n", + " p1_guess = h.last_action(\"Player 1\")\n", + " p2_guess = h.last_action(\"Player 2\")\n", + " return (p1_guess == h[0], p2_guess != h[0])\n", + "\n", + "for (p1_ok, p2_ok), group in g._get_groups(H.plays.by(outcome_key)).items():\n", + " g.make_outcome(\n", + " selector_for_histories(group), {\"Player 1\": int(p1_ok), \"Player 2\": int(p2_ok)},\n", + " f\"P1 {'correct' if p1_ok else 'wrong'}, P2 {'correct' if p2_ok else 'wrong'}\",\n", + " )\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "markdown", + "id": "02f8b9bb", + "metadata": {}, + "source": [ + "## 6. Absent-Minded Driver, with a further decision appended\n", + "\n", + "The classic Piccione–Rubinstein Absent-Minded Driver: one real binary decision (\"S\"/\"T\"),\n", + "faced *twice* without knowing which time it is, since the driver's own \"S\"-child shares\n", + "her first infoset. This variation goes one step further than the minimal version: after\n", + "her second \"S\", a *second* player gets a genuine, ordinary decision -- showing that\n", + "`append_infoset` composes normally with whatever construction comes after it; nothing\n", + "about the rest of the tree needs special treatment once the absent-minded infoset is set\n", + "up." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "26156c14", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:39.028098Z", + "iopub.status.busy": "2026-09-02T18:45:39.028013Z", + "iopub.status.idle": "2026-09-02T18:45:39.434611Z", + "shell.execute_reply": "2026-09-02T18:45:39.434339Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: False\n", + "Player 1's (first) infoset members: [(), ('S',)]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Absent-Minded Driver, extended\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"S\", \"T\"])\n", + "g.append_infoset(H.path(\"S\"), H.path())\n", + "g.append_move(H.path(\"S\", \"T\"), \"Player 2\", [\"r\", \"l\"])\n", + "\n", + "g.make_outcome(H.path(\"S\", \"S\"), {\"Player 1\": 1, \"Player 2\": -1}, \"SS\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"r\"), {\"Player 1\": 2, \"Player 2\": -2}, \"STr\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"l\"), {\"Player 1\": 3, \"Player 2\": -3}, \"STl\")\n", + "g.make_outcome(H.path(\"T\"), {\"Player 1\": 4, \"Player 2\": -4}, \"T\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 1's (first) infoset members:\",\n", + " sorted(g._get_histories(H.path()) + g._get_histories(H.path(\"S\"))),\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 0b8e7ea640..e05408d767 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -500,9 +500,9 @@ "metadata": {}, "outputs": [], "source": [ - "for infoset, mixed_action in eqm[\"Pl0\"]:\n", + "for number, (_infoset, mixed_action) in enumerate(eqm[\"Pl0\"], start=1):\n", " print(\n", - " f\"At information set {infoset.number}, \"\n", + " f\"At information set {number}, \"\n", " f\"Player 0 plays action 0 with probability: {mixed_action['p0a0']}\"\n", " f\" and action 1 with probability: {mixed_action['p0a1']}\"\n", " f\" and action 2 with probability: {mixed_action['p0a2']}\"\n", @@ -534,9 +534,9 @@ "metadata": {}, "outputs": [], "source": [ - "for infoset, mixed_action in eqm[\"Pl1\"]:\n", + "for number, (_infoset, mixed_action) in enumerate(eqm[\"Pl1\"], start=1):\n", " print(\n", - " f\"At information set {infoset.number}, \"\n", + " f\"At information set {number}, \"\n", " f\"Player 1 plays action 0 with probability: {mixed_action['p1a0']}\"\n", " f\" and action 1 with probability: {mixed_action['p1a1']}\"\n", " f\" and action 2 with probability: {mixed_action['p1a2']}\"\n", @@ -666,7 +666,7 @@ "id": "77dc34c8", "metadata": {}, "outputs": [], - "source": "gbt_one_card_poker = gbt.Game.new_tree(\n players=[\"Alice\", \"Bob\"],\n title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n)\n\ngbt_one_card_poker.append_event(\n gbt_one_card_poker.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)\n\nfor node in gbt_one_card_poker.root.children:\n gbt_one_card_poker.append_move(\n node,\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_one_card_poker.append_move(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"]\n ],\n player=\"Bob\",\n actions=[\"Call\", \"Fold\"]\n)\n\n# Alice folds, Bob wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Fold\"]\n ],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]\n ],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" + "source": "gbt_one_card_poker = gbt.Game.new_tree(\n players=[\"Alice\", \"Bob\"],\n title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n)\n\ngbt_one_card_poker.append_event(\n gbt.H.path(),\n actions={\"King\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)\n\nfor card in [\"King\", \"Queen\"]:\n gbt_one_card_poker.append_move(\n gbt.H.path(card),\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_one_card_poker.append_move(\n gbt.H.path(..., \"Bet\"),\n player=\"Bob\",\n actions=[\"Call\", \"Fold\"]\n)\n\n# Alice folds, Bob wins small\ngbt_one_card_poker.make_outcome(\n gbt.H.path(..., \"Fold\"),\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ngbt_one_card_poker.make_outcome(\n gbt.H.path(\"Queen\", \"Bet\", \"Call\"),\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ngbt_one_card_poker.make_outcome(\n gbt.H.path(\"King\", \"Bet\", \"Call\"),\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ngbt_one_card_poker.make_outcome(\n gbt.H.path(..., \"Bet\", \"Fold\"),\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" }, { "cell_type": "code", diff --git a/src/games/game.cc b/src/games/game.cc index 67ec8c400b..8621a9c6c4 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -277,6 +277,45 @@ void GameRep::RelabelPlayers(const std::map &p_labels) } } +//------------------------------------------------------------------------ +// GameRep: Outcomes +//------------------------------------------------------------------------ + +void GameRep::RelabelOutcomes(const std::map &p_labels) +{ + // Resolve each key to exactly one outcome of the game. + std::map assignment; + std::set relabeled; + for (const auto &[old_label, new_label] : p_labels) { + GameOutcomeRep *match = nullptr; + for (const auto &outcome : m_outcomes) { + if (outcome->GetLabel() == old_label) { + if (match) { + throw ValueException("Outcome label '" + old_label + "' is ambiguous in this game"); + } + match = outcome.get(); + } + } + if (!match) { + throw ValueException("No outcome with label '" + old_label + "' in this game"); + } + assignment[match] = new_label; + relabeled.insert(match); + } + // Replacement labels must be legal, unique against untouched outcomes, and pairwise distinct + std::set targets; + for (const auto &[outcome, new_label] : assignment) { + CheckOutcomeLabel(new_label, relabeled); + if (!targets.insert(new_label).second) { + throw ValueException("Outcome label '" + new_label + + "' would be duplicated by the relabelling"); + } + } + for (const auto &[outcome, new_label] : assignment) { + outcome->m_label = new_label; + } +} + //======================================================================== // MixedStrategyProfileRep //======================================================================== diff --git a/src/games/game.h b/src/games/game.h index aba2722b21..73fbdca2d4 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1248,6 +1248,14 @@ class GameRep : public std::enable_shared_from_this { /// Returns true if the game is perfect recall virtual bool IsPerfectRecall() const = 0; + /// Returns true if the player has perfect recall + virtual bool HasPerfectRecall(const GamePlayer &p_player) const + { + if (p_player->GetGame().get() != this) { + throw MismatchException(); + } + return true; + } /// Returns true if the information set is absent-minded virtual bool IsAbsentMinded(const GameInfoset &p_infoset) const { @@ -1465,6 +1473,9 @@ class GameRep : public std::enable_shared_from_this { { throw UndefinedException(); } + /// Reassign outcome labels. Keys of p_labels are current labels; values are their + /// replacements. + void RelabelOutcomes(const std::map &p_labels); //@} /// @name Nodes diff --git a/src/games/gametable.cc b/src/games/gametable.cc index ce07e94883..07a078a730 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -368,7 +368,8 @@ GameTableRep::GameTableRep(const std::vector &dim, bool p_sparseOutcomes /* else { m_outcomes = std::vector>(m_results.size()); std::generate(m_outcomes.begin(), m_outcomes.end(), [this, outc = 1]() mutable { - return std::make_shared(this, outc++, ""); + const auto number = outc++; + return std::make_shared(this, number, std::to_string(number)); }); std::transform(m_outcomes.begin(), m_outcomes.end(), m_results.begin(), [](const std::shared_ptr &c) { return c.get(); }); diff --git a/src/games/gametree.cc b/src/games/gametree.cc index b3c7a424cc..ffb2e15729 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1035,6 +1035,21 @@ bool GameTreeRep::IsPerfectRecall() const [](const auto &pair) { return pair.second.size() <= 1; }); } +bool GameTreeRep::HasPerfectRecall(const GamePlayer &p_player) const +{ + if (p_player->GetGame().get() != this) { + throw MismatchException(); + } + EnsureOwnPriorActions(); + + // Restriction of the check in IsPerfectRecall() to the information sets belonging to p_player. + return std::all_of(m_ownPriorActionInfo->infoset_map.cbegin(), + m_ownPriorActionInfo->infoset_map.cend(), + [player = p_player.get()](const auto &pair) { + return pair.first->m_player != player || pair.second.size() <= 1; + }); +} + bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const { if (p_infoset->GetGame().get() != this) { diff --git a/src/games/gametree.h b/src/games/gametree.h index c3452345d3..36078ca578 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -112,6 +112,7 @@ class GameTreeRep final : public GameExplicitRep { bool IsTree() const override { return true; } bool IsConstSum() const override; bool IsPerfectRecall() const override; + bool HasPerfectRecall(const GamePlayer &p_player) const override; /// Returns the smallest payoff to the player in any play of the game Rational GetPlayerMinPayoff(const GamePlayer &) const override; diff --git a/src/gui/efgtooltip.cc b/src/gui/efgtooltip.cc index 6a41f9a4e0..e6781dd19b 100644 --- a/src/gui/efgtooltip.cc +++ b/src/gui/efgtooltip.cc @@ -253,6 +253,15 @@ void OutcomeEditorPopup::BuildControls() outerSizer->Add(new wxStaticLine(m_contentPanel), 0, wxEXPAND); + auto *hintText = new wxStaticText(m_contentPanel, wxID_STATIC, + _("Escape cancels and closes this window. Enter accepts.")); + hintText->SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); + wxFont hintFont = hintText->GetFont(); + hintFont.SetPointSize(hintFont.GetPointSize() - 1); + hintText->SetFont(hintFont); + hintText->Wrap(FromDIP(260)); + outerSizer->Add(hintText, 0, wxALL, FromDIP(10)); + m_errorText = new wxStaticText(m_contentPanel, wxID_STATIC, wxEmptyString); m_errorText->SetForegroundColour(*wxRED); m_errorText->Wrap(FromDIP(260)); @@ -822,8 +831,10 @@ void AppendMovePopup::BuildControls() outerSizer->Add(new wxStaticLine(m_contentPanel), 0, wxEXPAND); - m_hintText = - new wxStaticText(m_contentPanel, wxID_ANY, _("Tab past the last action to add another")); + m_hintText = new wxStaticText( + m_contentPanel, wxID_ANY, + _("Tab past the last action to add another.\nEscape cancels and closes this window. Enter " + "accepts.")); m_hintText->SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); wxFont hintFont = m_hintText->GetFont(); hintFont.SetPointSize(hintFont.GetPointSize() - 1); diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 94538d0bf9..a0a7be3567 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -29,21 +29,41 @@ class InfosetIndexedVector(_LabeledVector): information set. Since information sets don't reliably have unique persistent labels, this is indexed - by any ``Node`` belonging to the information set (resolved to the information set - itself before lookup) rather than by a label: any member node is an equally valid - key, unlike ``NodeIndexedVector``. + by a `Selector` (an `H`-built expression) resolving to a single node belonging to the + information set -- resolved to the History of the information set's canonical member + before lookup, so any member node is an equally valid key -- rather than by a label, + unlike `NodeIndexedVector`. + + .. versionchanged:: 17.0.0 + Indexed by a `Selector` rather than a `Node` object. """ _label_kind = "information set" + _game = cython.declare(Game) - def __getitem__(self, node: Node) -> typing.Any: - resolved_node = cython.cast(Node, node) - infoset = resolved_node.infoset or resolved_node.event - if not infoset: - raise ValueError("node is terminal, has no information set") + def __init__(self, game: Game, values: collections.abc.Mapping) -> None: + self._game = game + _LabeledVector.__init__(self, values) + + def __getitem__(self, selector: Selector) -> typing.Any: + if not isinstance(selector, Selector): + raise TypeError( + f"{type(self).__name__} index must be a Selector, not " + f"{selector.__class__.__name__}" + ) + # Resolves via _resolve_infoset_or_event, not _resolve_infoset: infoset_probs's + # values span both personal information sets and chance events, so resolution + # must succeed for either kind here, leaving "wrong kind for this vector" (e.g. + # a chance node in infoset_values, which only holds personal ones) to surface + # as the KeyError below, from _values simply not having that entry -- not as a + # resolution-time error that would also wrongly block a valid chance lookup in + # infoset_probs. + resolved_node = self._game._resolve_infoset_or_event( + selector, f"{type(self).__name__}.__getitem__" + ) try: - return self._values[infoset] + return self._values[_canonical_history(resolved_node)] except KeyError: - raise KeyError(f"no {self._label_kind} for this node") from None + raise KeyError(f"no {self._label_kind} for this selector") from None @cython.cclass @@ -126,15 +146,18 @@ class MixedAction: An immutable snapshot taken from a ``MixedBehaviorProfile`` at retrieval time: it does not reflect later changes to the profile, and cannot itself be modified. The - information set is accessible via `infoset`. + information set is identified by the history that was resolved to reach it, + accessible via `history`. .. versionchanged:: 17.0.0 No longer a live view onto the profile: holds its own copy of the probabilities, and can no longer be assigned into. Set a distribution via - ``MixedBehaviorProfile.__setitem__`` instead. + ``MixedBehaviorProfile.__setitem__`` instead. `infoset` (an ``Infoset``) replaced + by `history` (the ``History`` -- a plain tuple of action labels -- of the node + that was resolved to identify the information set). """ - _infoset = cython.declare(Infoset) + _history = cython.declare(tuple) _values = cython.declare(dict) def __init__(self, *args, **kwargs) -> None: @@ -142,16 +165,16 @@ class MixedAction: @staticmethod @cython.cfunc - def wrap(infoset: Infoset, values: dict) -> MixedAction: + def wrap(history: tuple, values: dict) -> MixedAction: obj: MixedAction = MixedAction.__new__(MixedAction) - obj._infoset = infoset + obj._history = history obj._values = values return obj @property - def infoset(self) -> Infoset: - """The information set over which this mixed action is defined.""" - return self._infoset + def history(self) -> tuple: + """The History of the node that was resolved to identify this information set.""" + return self._history def __repr__(self) -> str: return str(self._values) @@ -172,7 +195,7 @@ class MixedAction: def __eq__(self, other: typing.Any) -> bool: if isinstance(other, collections.abc.Mapping): return self._values == dict(other) - if not isinstance(other, MixedAction) or self.infoset != other.infoset: + if not isinstance(other, MixedAction) or self.history != other.history: return False return self._values == cython.cast(MixedAction, other)._values @@ -235,14 +258,16 @@ class MixedBehavior: """ _player = cython.declare(str) _values = cython.declare(dict) + _game = cython.declare(Game) def __init__(self, *args, **kwargs) -> None: raise ValueError("Cannot create a MixedBehavior outside a Game.") @staticmethod @cython.cfunc - def wrap(player: str, values: dict) -> MixedBehavior: + def wrap(game: Game, player: str, values: dict) -> MixedBehavior: obj: MixedBehavior = MixedBehavior.__new__(MixedBehavior) + obj._game = game obj._player = player obj._values = values return obj @@ -276,7 +301,7 @@ class MixedBehavior: def __len__(self) -> int: return len(self._values) - def __iter__(self) -> typing.Iterator[tuple[Infoset, MixedAction], None, None]: + def __iter__(self) -> typing.Iterator[tuple[tuple, MixedAction], None, None]: """Iterate over the mixed actions specified by the mixed behavior. A ``MixedBehavior`` is a collection of ``MixedAction``\\ s, one per information @@ -288,37 +313,49 @@ class MixedBehavior: Previously iterated over individual actions and their probabilities; use ``MixedAction``'s own iteration for that at a specific information set. + .. versionchanged:: 17.0.0 + + Yields the History of the information set's canonical member (its first, + in pre-order depth-first order) instead of an ``Infoset``. + Yields ------ - infoset : Infoset - An information set belonging to the player + history : tuple + The History identifying an information set belonging to the player action : MixedAction The player's mixed action specified at the information set """ yield from self._values.items() - def __getitem__(self, index: Node) -> MixedAction: - """Returns the mixed action at the information set containing `index`. + def __getitem__(self, selector: Selector) -> MixedAction: + """Returns the mixed action at the information set `selector` resolves to. Parameters ---------- - index : Node - A node belonging to the information set to return. + selector : Selector + An `H`-built expression resolving to a single node belonging to the + information set to return. Raises ------ - MismatchError - If `index` is a ``Node`` from a different game, or belongs to an - information set that isn't this player's. + TypeError + If `selector` is not a ``Selector``. ValueError - If `index` is a terminal node, which belongs to no information set. + If `selector` resolves to a terminal node, which belongs to no + information set, or to a chance event. + MismatchError + If the resolved information set does not belong to this player. """ - infoset = cython.cast(Infoset, index.infoset) - if not infoset: - raise ValueError("node is terminal, has no information set") + if not isinstance(selector, Selector): + raise TypeError( + f"MixedBehavior index must be Selector, not {selector.__class__.__name__}" + ) + infoset = self._game._resolve_infoset(selector, "MixedBehavior.__getitem__") if infoset.player != self._player: - raise MismatchError("node must belong to this player") - return self._values[infoset] + raise MismatchError( + "selector must resolve to an information set belonging to this player" + ) + return self._values[_canonical_history(infoset)] @cython.cclass @@ -388,77 +425,55 @@ class MixedBehaviorProfile: Parameters ---------- - index : str or Node + index : str or Selector The part of the profile to return: * If `index` is a ``str``, returns a ``MixedBehavior`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is a ``Node``, returns a ``MixedAction`` over the actions at - the node's information set. + * If `index` is a ``Selector`` (an `H`-built expression) resolving to a + single node, returns a ``MixedAction`` over the actions at that + node's information set. Raises ------ TypeError - If `index` is not a ``str`` or a ``Node``. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``str`` or a ``Selector``. ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is a ``Selector`` resolving to a terminal node, which + belongs to no information set, or to a chance event. KeyError If `index` is a ``str`` and no player in the game has that label. """ self._check_validity() - if isinstance(index, Node): - return self._mixed_action_at(self._resolve_infoset_for_node(index)) if isinstance(index, str): values = { - node.infoset: self._mixed_action_at(node.infoset) - for node in self.game.get_infosets(index) + _canonical_history(node): self._mixed_action_at(node) + for node in self.game._get_infosets(index) } - return MixedBehavior.wrap(index, values) + return MixedBehavior.wrap(self.game, index, values) + if isinstance(index, Selector): + resolved_infoset = self.game._resolve_infoset( + index, "MixedBehaviorProfile.__getitem__" + ) + return self._mixed_action_at(resolved_infoset) raise TypeError( - f"profile index must be str or Node, not {index.__class__.__name__}" + f"profile index must be str or Selector, not {index.__class__.__name__}" ) - def _resolve_infoset_for_node(self, node: Node) -> Infoset: - """Resolves the personal player's information set containing node. - - Raises - ------ - MismatchError - If `node` belongs to a different game. - ValueError - If `node` resolves to a chance event, or is terminal, and so belongs to - no personal player's information set. - """ - if node.game != self.game: - raise MismatchError("node must belong to this game") - infoset = cython.cast(Infoset, node.infoset) - if not infoset: - if node.event: - raise ValueError( - "node belongs to a chance event, not a personal player's " - "information set" - ) - raise ValueError("node is terminal, has no information set") - return infoset - - def _all_infosets(self) -> typing.Iterator[Infoset]: - """Iterates over every information set and event in the game.""" + def _all_infosets(self) -> typing.Iterator[Node]: + """Iterates over a representative node of every information set and event in + the game.""" for player in self.game.players: - for node in self.game.get_infosets(player): - yield node.infoset - for node in self.game.get_events(): - yield node.event - - def _personal_infosets(self) -> typing.Iterator[Infoset]: - """Iterates over every information set in the game belonging to a personal - player, excluding the chance player's. + yield from self.game._get_infosets(player) + yield from self.game._get_events() + + def _personal_infosets(self) -> typing.Iterator[Node]: + """Iterates over a representative node of every information set in the game + belonging to a personal player, excluding the chance player's. """ for player in self.game.players: - for node in self.game.get_infosets(player): - yield node.infoset + yield from self.game._get_infosets(player) # The public API above is implemented once here and dispatches to the hooks below, # each of which is implemented by a concrete dtype-specific subclass @@ -491,10 +506,6 @@ class MixedBehaviorProfile: """ raise NotImplementedError - def _is_defined_at(self, infoset: Infoset) -> bool: - """Returns whether the profile specifies a probability distribution at infoset.""" - raise NotImplementedError - def _payoff(self, player: str) -> ProfileDType: """Returns the expected payoff to player.""" raise NotImplementedError @@ -509,13 +520,13 @@ class MixedBehaviorProfile: """Returns the probability that play reaches node.""" raise NotImplementedError - def _infoset_prob(self, infoset: _InfosetOrEvent) -> ProfileDType: - """Returns the probability that play reaches infoset.""" + def _infoset_prob(self, node: Node) -> ProfileDType: + """Returns the probability that play reaches node's information set or event.""" raise NotImplementedError - def _infoset_value(self, infoset: Infoset) -> ProfileDType | None: - """Returns the expected payoff to the player owning infoset, conditional on - reaching it; None if it is unreached. + def _infoset_value(self, node: Node) -> ProfileDType | None: + """Returns the expected payoff to the player owning node's information set, + conditional on reaching it; None if it is unreached. """ raise NotImplementedError @@ -535,8 +546,9 @@ class MixedBehaviorProfile: """Returns the regret to playing action.""" raise NotImplementedError - def _infoset_regret(self, infoset: Infoset) -> ProfileDType: - """Returns the regret of the player owning infoset for their behavior at it.""" + def _infoset_regret(self, node: Node) -> ProfileDType: + """Returns the regret of the player owning node's information set for their + behavior at it.""" raise NotImplementedError def _agent_max_regret(self) -> ProfileDType: @@ -573,17 +585,18 @@ class MixedBehaviorProfile: """ raise NotImplementedError - def _mixed_action_at(self, infoset: Infoset) -> MixedAction: - """Returns a snapshot of the mixed action at infoset, as of now.""" + def _mixed_action_at(self, node: Node) -> MixedAction: + """Returns a snapshot of the mixed action at node's information set, as of + now.""" values: dict = {} - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions(): values[a.deref().GetLabel().decode("utf-8")] = self._getprob_action(a) - return MixedAction.wrap(infoset, values) + return MixedAction.wrap(_history_of(node), values) def _setprob_infoset( - self, infoset: Infoset, distribution: collections.abc.Mapping, sparse: bool + self, node: Node, distribution: collections.abc.Mapping, sparse: bool ) -> None: - """Validates and sets the whole mixed action for infoset. + """Validates and sets the whole mixed action for node's information set. Every key of `distribution` must be one of the information set's action labels. If `sparse` is True, actions `distribution` omits are treated as @@ -595,7 +608,7 @@ class MixedBehaviorProfile: f"a mixed action must be set from a Mapping from action label to " f"weight, not {distribution.__class__.__name__}" ) - labels = set(infoset.actions) + labels = set(node.actions) given = set(distribution.keys()) unknown = given - labels if unknown: @@ -614,10 +627,10 @@ class MixedBehaviorProfile: raise ValueError("a mixed action's weights must be non-negative") if all(v == 0 for v in values.values()): raise ValueError("a mixed action's weights must not all be zero") - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions(): self._setprob_action(a, values[a.deref().GetLabel().decode("utf-8")]) - def __setitem__(self, index: Node, distribution: collections.abc.Mapping) -> None: + def __setitem__(self, index: Selector, distribution: collections.abc.Mapping) -> None: """Sets the mixed action at the information set containing `index`. `distribution` need not specify a weight for every one of the information @@ -626,8 +639,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : Selector + An `H`-built expression resolving to a single node belonging to the + information set to set. distribution : Mapping[str, Any] A non-negative weight for some or all of the information set's actions, keyed by action label; actions it omits are treated as having weight @@ -638,14 +652,13 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``Selector``, or `distribution` is not a Mapping. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if any weight cannot be interpreted as a number; if any weight - is negative; or if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no + information set, or to a chance event; if any key of `distribution` + is not one of the information set's action labels; if any weight + cannot be interpreted as a number; if any weight is negative; or if + the weights are all zero. See Also -------- @@ -654,13 +667,13 @@ class MixedBehaviorProfile: silently defaulting omitted ones to zero. """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, Selector): + raise TypeError(f"profile index must be Selector, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "MixedBehaviorProfile.__setitem__") self._setprob_infoset(infoset, distribution, sparse=True) def set_mixed_action( - self, index: Node, distribution: collections.abc.Mapping, sparse: bool = False + self, index: Selector, distribution: collections.abc.Mapping, sparse: bool = False ) -> None: """Sets the mixed action at the information set containing `index`. @@ -674,8 +687,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : Selector + An `H`-built expression resolving to a single node belonging to the + information set to set. distribution : Mapping[str, Any] A non-negative weight for the information set's actions, keyed by action label. A weight may be any value Gambit can interpret as a @@ -690,46 +704,25 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``Selector``, or `distribution` is not a Mapping. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if `sparse` is False and `distribution` omits an action; if any - weight cannot be interpreted as a number; if any weight is negative; or - if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no + information set, or to a chance event; if any key of `distribution` + is not one of the information set's action labels; if `sparse` is + False and `distribution` omits an action; if any weight cannot be + interpreted as a number; if any weight is negative; or if the + weights are all zero. See Also -------- __setitem__ """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, Selector): + raise TypeError(f"profile index must be Selector, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "MixedBehaviorProfile.set_mixed_action") self._setprob_infoset(infoset, distribution, sparse=sparse) - def is_defined_at(self, infoset: NodeReference) -> bool: - """Returns whether the profile has probabilities defined at the information set. - A profile can be well-defined if probabilities are not specified at some information sets, - as long as those information sets are reached with zero probability. - - Parameters - ---------- - infoset : Node or str - A node belonging to the information set to check, or such a node's label. - - Raises - ------ - MismatchError - If `infoset` is a ``Node`` from a different game. - KeyError - If `infoset` is a string and no node in the game has that label. - """ - self._check_validity() - return self._is_defined_at(self.game._resolve_infoset(infoset, "is_defined_at")) - @property def payoffs(self) -> PayoffVector: """Returns the expected payoff to each player, if all players play according to @@ -745,10 +738,15 @@ class MixedBehaviorProfile: def node_values(self) -> NodeValuesVector: """Returns the expected payoff to each player conditional on play reaching each node, if all players play according to the profile, grouped by player. + + .. versionchanged:: 17.0.0 + Keyed by each node's History rather than a ``Node`` object. """ self._check_validity() return NodeValuesVector({ - p: NodeValueVector({n: self._node_value(p, n) for n in self.game.nodes}) + p: NodeValueVector({ + _history_of(n): self._node_value(p, n) for n in self.game._all_nodes() + }) for p in self.game.players }) @@ -765,8 +763,9 @@ class MixedBehaviorProfile: MixedBehaviorProfile.infoset_probs """ self._check_validity() - return InfosetValueVector({ - infoset: self._infoset_value(infoset) for infoset in self._personal_infosets() + return InfosetValueVector(self.game, { + _canonical_history(node): self._infoset_value(node) + for node in self._personal_infosets() }) @property @@ -783,21 +782,26 @@ class MixedBehaviorProfile: MixedBehaviorProfile.infoset_probs """ self._check_validity() - return ActionValuesVector({ - infoset: ActionValueVector({ + return ActionValuesVector(self.game, { + _canonical_history(node): ActionValueVector({ a.deref().GetLabel().decode("utf-8"): self._action_value(a) - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() }) - for infoset in self._personal_infosets() + for node in self._personal_infosets() }) @property def realiz_probs(self) -> RealizProbVector: """Returns the probability with which each node is reached, if all players play according to the profile. + + .. versionchanged:: 17.0.0 + Keyed by each node's History rather than a ``Node`` object. """ self._check_validity() - return RealizProbVector({n: self._realiz_prob(n) for n in self.game.nodes}) + return RealizProbVector({ + _history_of(n): self._realiz_prob(n) for n in self.game._all_nodes() + }) @property def infoset_probs(self) -> InfosetProbVector: @@ -817,8 +821,9 @@ class MixedBehaviorProfile: MixedBehaviorProfile.beliefs """ self._check_validity() - return InfosetProbVector({ - infoset: self._infoset_prob(infoset) for infoset in self._all_infosets() + return InfosetProbVector(self.game, { + _canonical_history(node): self._infoset_prob(node) + for node in self._all_infosets() }) @property @@ -842,9 +847,14 @@ class MixedBehaviorProfile: See Also -------- MixedBehaviorProfile.infoset_probs + + .. versionchanged:: 17.0.0 + Keyed by each node's History rather than a ``Node`` object. """ self._check_validity() - return BeliefVector({n: self._belief(n) for n in self.game.nodes}) + return BeliefVector({ + _history_of(n): self._belief(n) for n in self.game._all_nodes() + }) @property def action_regrets(self) -> ActionRegretsVector: @@ -865,12 +875,12 @@ class MixedBehaviorProfile: agent_max_regret """ self._check_validity() - return ActionRegretsVector({ - infoset: ActionRegretVector({ + return ActionRegretsVector(self.game, { + _canonical_history(node): ActionRegretVector({ a.deref().GetLabel().decode("utf-8"): self._action_regret(a) - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() }) - for infoset in self._personal_infosets() + for node in self._personal_infosets() }) @property @@ -892,8 +902,9 @@ class MixedBehaviorProfile: agent_max_regret """ self._check_validity() - return InfosetRegretVector({ - infoset: self._infoset_regret(infoset) for infoset in self._personal_infosets() + return InfosetRegretVector(self.game, { + _canonical_history(node): self._infoset_regret(node) + for node in self._personal_infosets() }) def agent_max_regret(self) -> ProfileDType: @@ -1036,9 +1047,6 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def __len__(self) -> int: return deref(self.profile).BehaviorProfileLength() - def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset._resolve()) - @cython.cfunc def _getprob_action(self, index: c_GameAction) -> object: return deref(self.profile).getaction(index) @@ -1077,11 +1085,13 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _realiz_prob(self, node: Node) -> float: return deref(self.profile).GetRealizProb(node.node) - def _infoset_prob(self, infoset: _InfosetOrEvent) -> float: - return deref(self.profile).GetInfosetProb(infoset._resolve()) + def _infoset_prob(self, node: Node) -> float: + return deref(self.profile).GetInfosetProb(cython.cast(Node, node)._infoset_handle()) - def _infoset_value(self, infoset: Infoset) -> float | None: - cdef optional[double] value = deref(self.profile).GetPayoff(infoset._resolve()) + def _infoset_value(self, node: Node) -> float | None: + cdef optional[double] value = deref(self.profile).GetPayoff( + cython.cast(Node, node)._infoset_handle() + ) if value.has_value(): return value.value() return None @@ -1102,8 +1112,8 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _action_regret(self, action: c_GameAction) -> object: return deref(self.profile).GetRegret(action) - def _infoset_regret(self, infoset: Infoset) -> float: - return deref(self.profile).GetRegret(infoset._resolve()) + def _infoset_regret(self, node: Node) -> float: + return deref(self.profile).GetRegret(cython.cast(Node, node)._infoset_handle()) def _agent_max_regret(self) -> float: return deref(self.profile).GetAgentMaxRegret() @@ -1168,9 +1178,6 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def __len__(self) -> int: return deref(self.profile).BehaviorProfileLength() - def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset._resolve()) - @cython.cfunc def _getprob_action(self, index: c_GameAction) -> object: return rat_to_py(deref(self.profile).getaction(index)) @@ -1210,11 +1217,15 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _realiz_prob(self, node: Node) -> Rational: return rat_to_py(deref(self.profile).GetRealizProb(node.node)) - def _infoset_prob(self, infoset: _InfosetOrEvent) -> Rational: - return rat_to_py(deref(self.profile).GetInfosetProb(infoset._resolve())) + def _infoset_prob(self, node: Node) -> Rational: + return rat_to_py( + deref(self.profile).GetInfosetProb(cython.cast(Node, node)._infoset_handle()) + ) - def _infoset_value(self, infoset: Infoset) -> Rational | None: - cdef optional[c_Rational] value = deref(self.profile).GetPayoff(infoset._resolve()) + def _infoset_value(self, node: Node) -> Rational | None: + cdef optional[c_Rational] value = deref(self.profile).GetPayoff( + cython.cast(Node, node)._infoset_handle() + ) if value.has_value(): return rat_to_py(value.value()) return None @@ -1235,8 +1246,8 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _action_regret(self, action: c_GameAction) -> object: return rat_to_py(deref(self.profile).GetRegret(action)) - def _infoset_regret(self, infoset: Infoset) -> Rational: - return rat_to_py(deref(self.profile).GetRegret(infoset._resolve())) + def _infoset_regret(self, node: Node) -> Rational: + return rat_to_py(deref(self.profile).GetRegret(cython.cast(Node, node)._infoset_handle())) def _agent_max_regret(self) -> Rational: return rat_to_py(deref(self.profile).GetAgentMaxRegret()) @@ -1263,13 +1274,12 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _as_float(self) -> MixedBehaviorProfileDouble: profile: MixedBehaviorProfileDouble = self.game.mixed_behavior_profile() for player in self.game.players: - for node in self.game.get_infosets(player): - infoset = node.infoset + for node in self.game._get_infosets(player): profile._setprob_infoset( - infoset, + node, { a.deref().GetLabel().decode("utf-8"): float(self._getprob_action(a)) - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() }, sparse=True, ) diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 1abee090a1..38da83e1d4 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -28,19 +28,25 @@ class ActionSupport(_LabelSet): """A set of actions at a specified information set in a `BehaviorSupportProfile`. An immutable snapshot taken from a ``BehaviorSupportProfile`` at retrieval time: it - does not reflect later changes to the profile. The information set is accessible - via `infoset`. + does not reflect later changes to the profile. The information set is identified by + the history that was resolved to reach it, accessible via `history`. + + .. versionchanged:: 17.0.0 + `infoset` (an ``Infoset``) replaced by `history` (the ``History`` -- a plain + tuple of action labels -- of the node that was resolved to identify the + information set). """ @staticmethod @cython.cfunc - def wrap(infoset: Infoset, actions: tuple) -> ActionSupport: + def wrap(history: tuple, actions: tuple) -> ActionSupport: obj: ActionSupport = ActionSupport.__new__(ActionSupport) - obj._owner = infoset + obj._owner = history obj._labels = actions return obj @property - def infoset(self) -> Infoset: + def history(self) -> tuple: + """The History of the node that was resolved to identify this information set.""" return self._owner @@ -55,14 +61,16 @@ class BehaviorSupport: """ _player = cython.declare(str) _values = cython.declare(dict) + _game = cython.declare(Game) def __init__(self, *args, **kwargs) -> None: raise ValueError("Cannot create a BehaviorSupport outside a Game.") @staticmethod @cython.cfunc - def wrap(player: str, values: dict) -> BehaviorSupport: + def wrap(game: Game, player: str, values: dict) -> BehaviorSupport: obj: BehaviorSupport = BehaviorSupport.__new__(BehaviorSupport) + obj._game = game obj._player = player obj._values = values return obj @@ -97,22 +105,35 @@ class BehaviorSupport: """ yield from self._values.values() - def __getitem__(self, infoset: Infoset) -> ActionSupport: - """Returns the action support at `infoset`. + def __getitem__(self, selector: Selector) -> ActionSupport: + """Returns the action support at the information set `selector` resolves to. Parameters ---------- - infoset : Infoset - The information set to return the support for. + selector : Selector + An `H`-built expression resolving to a single node belonging to the + information set to return the support for. Raises ------ + TypeError + If `selector` is not a ``Selector``. + ValueError + If `selector` resolves to a terminal node, which belongs to no + information set, or to a chance event. MismatchError - If `infoset` does not belong to this player. + If the resolved information set does not belong to this player. """ - if infoset.player != self._player: - raise MismatchError("infoset must belong to this player") - return self._values[infoset] + if not isinstance(selector, Selector): + raise TypeError( + f"BehaviorSupport index must be Selector, not {selector.__class__.__name__}" + ) + resolved_node = self._game._resolve_infoset(selector, "BehaviorSupport.__getitem__") + if resolved_node.player != self._player: + raise MismatchError( + "selector must resolve to an information set belonging to this player" + ) + return self._values[_canonical_history(resolved_node)] @cython.cclass @@ -167,70 +188,50 @@ class BehaviorSupportProfile: Parameters ---------- - index : str, Node, or Infoset + index : str or Selector The part of the profile to return: * If `index` is a ``str``, returns a ``BehaviorSupport`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is a ``Node`` or an ``Infoset`` (e.g. one obtained from - iterating a ``BehaviorSupport``), returns an ``ActionSupport`` over the - actions in the support at the information set. + * If `index` is a ``Selector`` (an `H`-built expression) resolving to a + single node, returns an ``ActionSupport`` over the actions in the + support at that node's information set. Raises ------ TypeError - If `index` is not a ``str``, a ``Node``, or an ``Infoset``. - MismatchError - If `index` is a ``Node`` or ``Infoset`` from a different game. + If `index` is not a ``str`` or a ``Selector``. ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is a ``Selector`` resolving to a terminal node, which belongs + to no information set, or to a chance event. KeyError If `index` is a ``str`` and no player in the game has that label. """ - resolved_infoset = self._resolve_infoset_arg(index) - if resolved_infoset is not None: - if resolved_infoset.game != self.game: - raise MismatchError("infoset must be part of the same game") - return self._action_support_at(resolved_infoset) if isinstance(index, str): values = { - node.infoset: self._action_support_at(node.infoset) - for node in self.game.get_infosets(index) + _canonical_history(node): self._action_support_at(node) + for node in self.game._get_infosets(index) } - return BehaviorSupport.wrap(index, values) + return BehaviorSupport.wrap(self.game, index, values) + if isinstance(index, Selector): + resolved_node = self.game._resolve_infoset( + index, "BehaviorSupportProfile.__getitem__" + ) + return self._action_support_at(resolved_node) raise TypeError( - f"profile index must be str, Node, or Infoset, not {index.__class__.__name__}" + f"profile index must be str or Selector, not {index.__class__.__name__}" ) - @cython.cfunc - def _resolve_infoset_arg(self, index: object) -> object: - """Resolves index to the Infoset it identifies if it is a Node or an Infoset, - or returns None if index is neither (e.g. a player label str). - """ - if isinstance(index, Node): - node = cython.cast(Node, index) - resolved = cython.cast(Infoset, node.infoset) - if not resolved: - if node.event: - raise ValueError( - "index resolves to a chance event; a behavior support is only " - "defined for a personal player's information sets" - ) - raise ValueError("index resolves to no information set (the node is terminal)") - return resolved - if isinstance(index, Infoset): - return index - return None - - def _action_support_at(self, infoset: Infoset) -> ActionSupport: - """Returns a snapshot of the action support at infoset, as of now.""" - infoset_handle = cython.cast(Infoset, infoset)._resolve() + def _action_support_at(self, node: Node) -> ActionSupport: + """Returns a snapshot of the action support at node's information set, as of + now.""" + infoset_handle: c_GameInfoset = cython.cast(Node, node)._infoset_handle() actions = tuple( a.deref().GetLabel().decode("utf-8") for a in deref(self.profile).GetActions(infoset_handle) ) - return ActionSupport.wrap(infoset, actions) + return ActionSupport.wrap(_history_of(node), actions) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -241,13 +242,13 @@ class BehaviorSupportProfile: self.profile = make_shared[c_BehaviorSupportProfile](deref(self.profile)) @cython.cfunc - def _set_support(self, infoset: Infoset, actions: object) -> cython.void: - """Validates and sets the whole support at infoset. + def _set_support(self, node: Node, actions: object) -> cython.void: + """Validates and sets the whole support at node's information set. Every entry of `actions` must be one of the information set's action labels, and at least one must be given. """ - labels = set(infoset.actions) + labels = set(node.actions) given = set(actions) unknown = given - labels if unknown: @@ -260,7 +261,7 @@ class BehaviorSupportProfile: # Actions to keep are added first, so that a subsequent removal is never asked # to remove the last remaining action at the information set. (Unlike # RemoveStrategy, RemoveAction does not itself guard against emptying its scope.) - action_handles = cython.cast(Infoset, infoset)._resolve().deref().GetActions() + action_handles = cython.cast(Node, node)._infoset_handle().deref().GetActions() for a in action_handles: if a.deref().GetLabel().decode("utf-8") in given: deref(self.profile).AddAction(a) @@ -273,10 +274,9 @@ class BehaviorSupportProfile: Parameters ---------- - infoset : Node or Infoset - A node belonging to the information set whose support is to be set, or - the information set itself (e.g. one obtained from iterating a - ``BehaviorSupport``). + infoset : Selector + An `H`-built expression resolving to a single node belonging to the + information set whose support is to be set. actions : Iterable[str] The labels of the actions which should be in the support at the information set. Every other action at the information set is removed @@ -285,22 +285,21 @@ class BehaviorSupportProfile: Raises ------ TypeError - If `infoset` is not a ``Node`` or an ``Infoset``. - MismatchError - If `infoset` is a `Node` or `Infoset` from a different game. + If `infoset` is not a ``Selector``. ValueError If any entry of `actions` is not one of the information set's action - labels, or if `actions` is empty; or if `infoset` is a terminal node, - which belongs to no information set. + labels, or if `actions` is empty; or if `infoset` resolves to a + terminal node, which belongs to no information set, or to a chance + event. """ - resolved_infoset = self._resolve_infoset_arg(infoset) - if resolved_infoset is None: + if not isinstance(infoset, Selector): raise TypeError( - f"profile index must be Node or Infoset, not {infoset.__class__.__name__}" + f"profile index must be Selector, not {infoset.__class__.__name__}" ) - if resolved_infoset.game != self.game: - raise MismatchError("infoset must be part of the same game") - self._set_support(resolved_infoset, actions) + resolved_node = self.game._resolve_infoset( + infoset, "BehaviorSupportProfile.__setitem__" + ) + self._set_support(resolved_node, actions) def copy(self) -> BehaviorSupportProfile: """Creates a copy of the support profile. @@ -313,29 +312,28 @@ class BehaviorSupportProfile: """ return BehaviorSupportProfile.wrap(self.profile) - def is_reachable(self, infoset: typing.Any) -> bool: + def is_infoset_reachable(self, infoset: Selector) -> bool: """Returns whether `infoset` can be reached under this support, i.e. whether there is some path of play consistent with the support that reaches it. Parameters ---------- - infoset : Node, str, or Infoset - A node belonging to the information set to check, such a node's label, or - the information set itself (e.g. one obtained from iterating a - ``BehaviorSupport``). + infoset : Selector + An `H`-built expression resolving to a single node belonging to the + information set to check. Raises ------ - MismatchError - If `infoset` is a `Node` or `Infoset` from a different game. - KeyError - If `infoset` is a string and no node in the game has that label. + TypeError + If `infoset` is not a ``Selector``. + ValueError + If `infoset` resolves to a terminal node, which belongs to no + information set, or to a chance event. """ - resolved_infoset: Infoset - if isinstance(infoset, Infoset): - resolved_infoset = infoset - if resolved_infoset.game != self.game: - raise MismatchError("is_reachable(): infoset must be part of the same game") - else: - resolved_infoset = self.game._resolve_infoset(infoset, "is_reachable") - return deref(self.profile).IsReachable(resolved_infoset._resolve()) + if not isinstance(infoset, Selector): + raise TypeError( + f"is_infoset_reachable(): infoset must be a Selector, " + f"not {infoset.__class__.__name__}" + ) + resolved_node: Node = self.game._resolve_infoset(infoset, "is_infoset_reachable") + return deref(self.profile).IsReachable(resolved_node._infoset_handle()) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 2f70acdd54..bfe8f40ff1 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -421,9 +421,9 @@ def check_filters(game: gbt.Game) -> bool: if not game.is_tree: return False n_game_actions = sum( - len(node.infoset.actions) + len(game.get_actions(gbt.H.path(*history))) for player in game.players - for node in game.get_infosets(player) + for history in game.get_infosets(player) ) if n_game_actions != n_actions: return False @@ -447,9 +447,9 @@ def check_filters(game: gbt.Game) -> bool: if n_nodes is not None: if not game.is_tree: return False - if len(game.nodes) != n_nodes: + if len(game.get_histories(gbt.H.after())) != n_nodes: return False - if n_outcomes is not None and len(game.outcomes) != n_outcomes: + if n_outcomes is not None and len(game.get_outcomes()) != n_outcomes: return False if n_players is not None and len(game.players) != n_players: return False diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index d7863a2c31..dfc16192dd 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -244,10 +244,12 @@ def render_support_csv( fields = [ "".join( "1" if action in action_support else "0" - for action in action_support.infoset.actions + for action in support.game.get_actions(gbt.H.path(*history)) ) for player in support.game.players - for action_support in support[player] + for history, action_support in zip( + support.game.get_infosets(player), support[player], strict=True + ) ] else: fields = [ @@ -273,10 +275,13 @@ def render_profile_detail( 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 _name_or_number(node: gbt.Node) -> str: + # Gambit's Python API numbers nodes from 0; the C++ tools display the + # underlying (1-based) engine numbering. `Node._label`/`._number` are private + # (not part of the public API) but still Python-accessible, same as + # `Game._get_infosets` just below -- rendering needs the node's own label/engine + # number, which a History alone cannot provide. + return node._label if node._label else str(node._number() + 1) def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> str: @@ -294,6 +299,18 @@ def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> return "\n".join(lines) +def _history_of(node: gbt.Node) -> tuple: + """The plain-tuple History of `node`, walked via the private + `Node._parent`/`._prior_action`.""" + labels = [] + current = node + while current._parent() is not None: + labels.append(current._prior_action().label) + current = current._parent() + labels.reverse() + return tuple(labels) + + def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> str: lines = [] action_values = profile.action_values @@ -303,29 +320,41 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> lines.append(f"Behavior profile for player {number}:") lines.append("Infoset Action Prob Value") lines.append("------- ------- ----------- -----------") - for infoset, mixed_action in profile[player]: - infoset_name = _name_or_number(infoset) - values = action_values[next(iter(infoset.members))] - for action in infoset.actions: + # Numbered by position among the player's information sets: Infoset no + # longer exists as an object, so there is currently no infoset-level label + # to prefer over this, unlike _name_or_number's use for players/nodes/actions. + for infoset_number, (history, (_, mixed_action)) in enumerate( + zip(profile.game.get_infosets(player), profile[player], strict=True), start=1 + ): + selector = gbt.H.path(*history) + values = action_values[selector] + for action in profile.game.get_actions(selector): prob = mixed_action[action] value = values[action] value_text = format_value(value, decimals) if value is not None else "" lines.append( - f"{infoset_name:>7} {action:>7} " + f"{infoset_number:>7} {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]: - infoset_name = _name_or_number(infoset) - for node in infoset.members: - node_name = _name_or_number(node) - belief = beliefs[node] + # Uses the private, Node-returning `_get_infosets` (rather than the public + # History-returning `get_infosets`) because rendering needs each member's own + # `.label`/`.number` for display, which a History alone cannot provide; + # `Node.members` (still public) then gives the other members directly, and + # `_history_of` recovers the History each one needs to index + # `beliefs`/`realiz_probs` with. + for infoset_number, node in enumerate(profile.game._get_infosets(player), start=1): + for member in node.members: + node_name = _name_or_number(member) + member_history = _history_of(member) + belief = beliefs[member_history] belief_text = format_value(belief, decimals) if belief is not None else "" - realiz_text = format_value(realiz_probs[node], decimals) + realiz_text = format_value(realiz_probs[member_history], decimals) lines.append( - f"{infoset_name:>7} {node_name:>7} {belief_text:>11} {realiz_text:>11}" + f"{infoset_number:>7} {node_name:>7} " + f"{belief_text:>11} {realiz_text:>11}" ) lines.append("") return "\n".join(lines) @@ -371,9 +400,9 @@ def read_behavior_profiles_csv( the result via `~MixedBehaviorProfile.as_float`. """ count = sum( - len(node.infoset.actions) + len(game.get_actions(gbt.H.path(*history))) for player in game.players - for node in game.get_infosets(player) + for history in game.get_infosets(player) ) profiles = [] for line in pathlib.Path(path).read_text().splitlines(): @@ -387,8 +416,9 @@ def read_behavior_profiles_csv( raise ValueError(f"Error reading behavior profile from '{path}': {exc}") from None profile = game.mixed_behavior_profile(rational=True) for player in game.players: - for node in game.get_infosets(player): - profile[node] = {a: next(values) for a in node.infoset.actions} + for history in game.get_infosets(player): + selector = gbt.H.path(*history) + profile[selector] = {a: next(values) for a in game.get_actions(selector)} profiles.append(profile) return profiles diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index a29b0f08e5..8dce490284 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -304,6 +304,7 @@ cdef extern from "games/game.h": int NumOutcomes() except + c_GameOutcome GetOutcome(int) except +IndexError Outcomes GetOutcomes() except + + void RelabelOutcomes(stdmap[string, string]) except +ValueError int NumNodes() except + c_GameNode GetRoot() except + @@ -324,6 +325,7 @@ cdef extern from "games/game.h": stdvector[c_GameNode] GetPlays(c_GameInfoset) except + stdvector[c_GameNode] GetPlays(c_GameAction) except + bool IsPerfectRecall() except + + bool HasPerfectRecall(c_GamePlayer) except + bool IsAbsentMinded(c_GameInfoset) except + c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, stdvector[string]) except +ValueError @@ -345,7 +347,6 @@ cdef extern from "games/game.h": string) except +ValueError void MakeOutcomeNull(stdvector[c_GameNode]) except +ValueError void MakeOutcomeNull(stdvector[stdvector[c_GameStrategy]]) except +ValueError - void Reveal(c_GameInfoset, c_GamePlayer) except + void RelabelActions(c_GameInfoset, stdmap[string, string]) except +ValueError void SetMoveActions(c_GameInfoset, stdvector[string]) except +ValueError void SetEventActions(c_GameInfoset, stdvector[string], @@ -398,7 +399,6 @@ cdef extern from "games/behavmixed.h" namespace "Gambit": c_Game GetGame() except + bool IsInvalidated() int BehaviorProfileLength() except + - bool IsDefinedAt(c_GameInfoset) except + c_MixedBehaviorProfile[T] Normalize() # except + doesn't compile T getitem "operator[]"(int) except +IndexError T getaction "operator[]"(c_GameAction) except +IndexError diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 2d73be109a..ac1665248b 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -100,9 +100,6 @@ def _resolve_by_label(collection, label: str, scope: str, kind: str, kind_plural return matches[0] -NodeReference = Node | str -NodeReferenceSet = typing.Iterable[NodeReference] - ProfileDType = float | Rational @@ -175,11 +172,16 @@ class StrategyIndexedVector(_LabeledVector): @cython.cclass class NodeIndexedVector(_LabeledVector): - """A read-only mapping from a ``Node`` to a computed value, one entry per node. + """A read-only mapping from a node's History to a computed value, one entry + per node. Unlike ``PlayerIndexedVector``/``StrategyIndexedVector``, which are keyed by a stable - label, this is keyed by node identity: the value can genuinely differ between two - nodes, even nodes belonging to the same information set. + label, this is keyed by the node's own History -- a plain tuple of action labels + from the root, unique to that node -- since the value can genuinely differ between + two nodes, even nodes belonging to the same information set. + + .. versionchanged:: 17.0.0 + Keyed by a node's History rather than a ``Node`` object. """ _label_kind = "node" @@ -188,10 +190,9 @@ class NodeIndexedVector(_LabeledVector): # Includes ###################### -include "infoset.pxi" include "strategy.pxi" -include "outcome.pxi" include "node.pxi" +include "hsel.pxi" include "stratspt.pxi" include "behavspt.pxi" include "stratmixed.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 84c511663f..49586ea47b 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -150,9 +150,11 @@ class Game: players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): contingency = {p: str(i + 1) for p, i in zip(players, profile, strict=True)} - outcome = g.get_outcome(contingency) + resolved_outcome = g._get_contingency_outcome(contingency, "from_arrays") for array, player in zip(arrays, players, strict=True): - outcome[player] = array[profile] + resolved_outcome.deref().SetPayoff( + g._resolve_player(player, "from_arrays"), _to_number(array[profile]) + ) g.title = title return g @@ -239,9 +241,11 @@ class Game: players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): contingency = {p: str(i + 1) for p, i in zip(players, profile, strict=True)} - outcome = g.get_outcome(contingency) + resolved_outcome = g._get_contingency_outcome(contingency, "from_dict") for array, player in zip(arrays, players, strict=True): - outcome[player] = array[profile] + resolved_outcome.deref().SetPayoff( + g._resolve_player(player, "from_dict"), _to_number(array[profile]) + ) g.title = title return g @@ -308,15 +312,30 @@ class Game: def description(self, value: str) -> None: self.game.deref().SetDescription(value.encode("utf-8")) - def get_infosets(self, player: str) -> list[Node]: + def _get_infosets(self, player: str) -> list[Node]: + """Internal: like `get_infosets`, but keeps `Node` objects rather than + materializing each into a History -- used internally where the actual node + (not just its identifying History) is needed. + """ + if not self.is_tree: + raise UndefinedOperationError( + "Operation only defined for games with a tree representation" + ) + resolved_player = self._resolve_player(player, "get_infosets") + return [ + Node.wrap(infoset.deref().GetMember(1)) + for infoset in resolved_player.deref().GetInfosets() + ] + + def get_infosets(self, player: str) -> list[tuple]: """Returns a snapshot of the information sets belonging to the personal player `player`: the decisions at which that player chooses an action. - One representative member node is returned per information set, in the order - the information sets are encountered in the pre-order depth first traversal of - the game tree. This is a materialized snapshot, not a live view: it reflects - the game's state at the moment of the call, and does not change if the game is - subsequently mutated. + One representative member's History is returned per information set, in the + order the information sets are encountered in the pre-order depth first + traversal of the game tree. This is a materialized snapshot, not a live view: + it reflects the game's state at the moment of the call, and does not change if + the game is subsequently mutated. Parameters ---------- @@ -325,11 +344,16 @@ class Game: Returns ------- - list of Node - One representative member node per information set belonging to `player`. + list of tuple + The History of one representative member per information set belonging to + `player`. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + Returns each information set's representative as a History rather than a + ``Node`` object. + Raises ------ UndefinedOperationError @@ -340,46 +364,49 @@ class Game: ValueError If `player` is an empty string or all whitespace. """ + return [_history_of(node) for node in self._get_infosets(player)] + + def _get_events(self) -> list[Node]: + """Internal: like `get_events`, but keeps `Node` objects rather than + materializing each into a History -- used internally where the actual node + (not just its identifying History) is needed. + """ if not self.is_tree: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - resolved_player = self._resolve_player(player, "get_infosets") return [ - Node.wrap(infoset.deref().GetMember(1)) - for infoset in resolved_player.deref().GetInfosets() + Node.wrap(event.deref().GetMember(1)) + for event in self.game.deref().GetChance().deref().GetInfosets() ] - def get_events(self) -> list[Node]: + def get_events(self) -> list[tuple]: """Returns a snapshot of the chance player's events: the points of exogenous randomness, each with a probability distribution over its actions. - One representative member node is returned per event, in the order the events - are encountered in the pre-order depth first traversal of the game tree. This - is a materialized snapshot, not a live view: it reflects the game's state at - the moment of the call, and does not change if the game is subsequently - mutated. + One representative member's History is returned per event, in the order the + events are encountered in the pre-order depth first traversal of the game + tree. This is a materialized snapshot, not a live view: it reflects the + game's state at the moment of the call, and does not change if the game is + subsequently mutated. Returns ------- - list of Node - One representative member node per event. + list of tuple + The History of one representative member per event. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + Returns each event's representative as a History rather than a ``Node`` + object. + Raises ------ UndefinedOperationError If the game does not have a tree representation. """ - if not self.is_tree: - raise UndefinedOperationError( - "Operation only defined for games with a tree representation" - ) - return [ - Node.wrap(event.deref().GetMember(1)) - for event in self.game.deref().GetChance().deref().GetInfosets() - ] + return [_history_of(node) for node in self._get_events()] def get_strategies(self, player: str) -> list[str]: """Returns a snapshot of the labels of the strategies belonging to `player`. @@ -496,51 +523,336 @@ class Game: """The set of players in the game.""" return GamePlayers.wrap(self.game) - @property - def outcomes(self) -> GameOutcomes: - """The set of outcomes in the game.""" - return GameOutcomes.wrap(self.game) + def get_outcomes(self) -> list[str]: + """Returns the labels of the outcomes in the game. - @property - def nodes(self) -> GameNodes: - """The set of nodes in the game. + .. versionadded:: 17.0.0 + """ + return [ + o.deref().GetLabel().decode("utf-8") for o in self.game.deref().GetOutcomes() + ] - Iteration over this property yields the nodes in the order of depth-first search. + @property + def contingencies(self) -> pygambit.gameiter.Contingencies: + """An iterator over the contingencies in the game.""" + return pygambit.gameiter.Contingencies(self) - .. versionchanged:: 16.4 - Changed from a method ``nodes()`` to a property. + def _root(self) -> Node: + """The root node of the game. Not part of the public API; the public + equivalent is the trivial empty History, `()`, or `H.path()` as a Selector. + """ + if not self.is_tree: + raise UndefinedOperationError( + "root: only games with a tree representation have a root node" + ) + return Node.wrap(self.game.deref().GetRoot()) - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. + def _all_nodes(self) -> list: + """All nodes in the game, in depth-first traversal order. Not part of + the public API; the public equivalent is `Game.get_histories(H.after())`. """ if not self.is_tree: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) + return [Node.wrap(node) for node in self.game.deref().GetNodes()] + + def _get_nodes(self, selector: Selector) -> list[Node]: + """Evaluate `selector` (an `H`-built expression) against this game. + + Internal: the `H` selector algebra's evaluator, interpreting the + selector's ops in order, starting from the root, reusing `Node`'s + existing (private) navigation (`_children()`, `_plays()`) rather than + walking the C++ tree directly. Not part of the public API yet -- used to resolve + a `Selector`/`GroupedSelector` argument to `append_move`, + `append_event`, `append_infoset`, and `make_outcome`. + """ + current: list = None + for op in selector._ops: + if isinstance(op, _AfterStep): + candidates = self._all_nodes() if current is None else current + current = [n for n in candidates if _matches_suffix(n, op.labels)] + continue + if current is None: + current = [self._root()] + if isinstance(op, _PathStep): + for step in op.steps: + current = ( + [ + child + for node in current + for child in cython.cast(Node, node)._children() + ] + if step is Ellipsis + else [cython.cast(Node, node)._children()[step] for node in current] + ) + elif isinstance(op, _PlaysStep): + current = [ + play for node in current for play in cython.cast(Node, node)._plays() + ] + elif isinstance(op, _FilterStep): + current = [ + node for node in current + if op.predicate(HistoryView._wrap(node, _history_of(node))) + ] + else: + raise TypeError(f"_get_nodes(): unknown selector op {op!r}") + if current is None: + current = [self._root()] + return current + + def _get_histories(self, selector: Selector) -> list[tuple]: + """Evaluate `selector` (an `H`-built expression) against this game, + materializing each result as a `History` -- a plain tuple of action + labels from the root, carrying no reference to this game. + + Internal: the History-materializing counterpart to `_get_nodes`, kept + for use by `_get_groups` and tests. Not part of the public API yet. + """ + return [_history_of(node) for node in self._get_nodes(selector)] + + def get_histories(self, selector: Selector) -> list[tuple]: + """Returns the Histories of the nodes that `selector` resolves to. - return GameNodes.wrap(self.game) + Parameters + ---------- + selector : Selector + An `H`-built expression, evaluated against this game. - @property - def contingencies(self) -> pygambit.gameiter.Contingencies: - """An iterator over the contingencies in the game.""" - return pygambit.gameiter.Contingencies(self) + Returns + ------- + list of tuple + The Histories -- plain tuples of action labels from the root -- + of the matching nodes, in the order `selector` produces them. - @property - def root(self) -> Node: - """The root node of the game. + .. versionadded:: 17.0.0 Raises ------ - UndefinedOperationError - If the game does not hae a tree representation. + TypeError + If `selector` is not a `Selector`. """ - if not self.is_tree: - raise UndefinedOperationError( - "root: only games with a tree representation have a root node" + if not isinstance(selector, Selector): + raise TypeError( + f"get_histories(): selector must be a Selector, not " + f"{selector.__class__.__name__}" ) - return Node.wrap(self.game.deref().GetRoot()) + return self._get_histories(selector) + + def get_player(self, history: Selector) -> str | None: + """Returns the label of the player associated with the node that + `history` resolves to: the one who makes the decision, if this is a + personal node, or the chance player, if this is an event. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + str or None + The label of the player who owns the node, or `None` if the node + is terminal, which has no player. + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_player(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + resolved_node = self._resolve_node(history, "get_player") + return resolved_node.player + + def get_actions(self, history: Selector) -> list[str]: + """Returns the labels of the actions available at the node that + `history` resolves to, in the order they are defined. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + list of str + The labels of the actions at the node's current information set + or event, or an empty list if the node is terminal -- a node is + terminal exactly when this is empty. + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_actions(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + resolved_node = self._resolve_node(history, "get_actions") + infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() + if infoset_handle == cython.cast(c_GameInfoset, NULL): + return [] + return [ + a.deref().GetLabel().decode("utf-8") for a in infoset_handle.deref().GetActions() + ] + + def get_action_probs(self, history: Selector) -> dict[str, decimal.Decimal | Rational]: + """Returns the probability of each action at the node that `history` + resolves to, keyed by label, if it currently belongs to a chance event. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + dict of str to Decimal or Rational + The probability of each action, keyed by label, or an empty dict + if the node does not currently belong to a chance event -- + including a terminal node, or a personal player's node. + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_action_probs(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + resolved_node = self._resolve_node(history, "get_action_probs") + infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() + if ( + infoset_handle == cython.cast(c_GameInfoset, NULL) + or not infoset_handle.deref().IsChanceInfoset() + ): + return {} + result: dict = {} + for a in infoset_handle.deref().GetActions(): + result[a.deref().GetLabel().decode("utf-8")] = _decode_number( + cython.cast(string, infoset_handle.deref().GetActionProb(a)) + ) + return result + + def get_members(self, history: Selector) -> list[tuple]: + """Returns the Histories of the nodes which are members of the + information set or event that the node identified by `history` + currently belongs to. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + list of tuple + The Histories of the member nodes, or an empty list if the node + is currently terminal (belongs to no information set or event). + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_members(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + return [_history_of(member) for member in self._get_members(history)] + + def _get_members(self, history: Selector) -> list[Node]: + """Internal: like `get_members`, but keeps `Node` objects rather than + materializing each into a History -- used internally where the actual node + (not just its identifying History) is needed. `history` is assumed already + validated as a `Selector` by the caller. + """ + resolved_node = self._resolve_node(history, "get_members") + infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() + if infoset_handle == cython.cast(c_GameInfoset, NULL): + return [] + return [Node.wrap(member) for member in infoset_handle.deref().GetMembers()] + + def _group_nodes(self, grouped: GroupedSelector) -> dict: + """Internal: like `_get_groups`, but keeps `Node` objects rather than + materializing each into a `History` -- used by mutation methods that + need to resolve straight back to concrete nodes, avoiding a + Node -> History -> Node round trip. + + Applies `grouped`'s initial partition (`base`/`key`), then its + `post_ops` in order, each one per-group -- expanding/filtering each + group's own members independently, leaving the key untouched, except + that a `.plays` step refines the key by `recall_player`'s last action + at that point, if `with_recall` set one (see `GroupedSelector`'s + docstring for why). + """ + result: dict = {} + for node in self._get_nodes(grouped.base): + view: HistoryView = HistoryView._wrap(node, _history_of(node)) + key = grouped.key(view) + result.setdefault(key, []).append(node) + for op in grouped.post_ops: + next_result: dict = {} + for key, nodes in result.items(): + if isinstance(op, _PlaysStep): + expanded = [ + play for node in nodes for play in cython.cast(Node, node)._plays() + ] + if grouped.recall_player is None: + next_result[key] = expanded + else: + for play in expanded: + refined_key = (key, _last_action(play, grouped.recall_player)) + next_result.setdefault(refined_key, []).append(play) + continue + if isinstance(op, _AfterStep): + next_result[key] = [n for n in nodes if _matches_suffix(n, op.labels)] + continue + raise TypeError(f"_group_nodes(): unknown post-op {op!r}") + result = next_result + return result + + def _get_groups(self, grouped: GroupedSelector) -> dict: + """Evaluate a `.by(callable)`-built `GroupedSelector` against this + game, returning a dict from each distinct key to the list of + Histories that produced it. + + Internal: the History-materializing counterpart to `_group_nodes`, + kept for use by tests. Not part of the public API yet. + """ + return { + key: [_history_of(node) for node in nodes] + for key, nodes in self._group_nodes(grouped).items() + } @property def is_const_sum(self) -> bool: @@ -553,9 +865,45 @@ class Game: By convention, games with a strategic representation have perfect recall as they are treated as simultaneous-move games. + + See Also + -------- + Game.has_perfect_recall """ return self.game.deref().IsPerfectRecall() + def has_perfect_recall(self, player: str) -> bool: + """Returns whether `player` has perfect recall. + + A player has perfect recall if, at each of the player's information sets, every + member node is reached by the same sequence of the player's own prior actions; + that is, the player never forgets an action they took previously, nor information + they previously knew. A game has perfect recall if and only if every player does. + + By convention, in games with a strategic representation every player has perfect + recall as such games are treated as simultaneous-move games. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + player : str + The label of the player. + + Raises + ------ + KeyError + If no player in the game has label `player`. + ValueError + If `player` is an empty string or all whitespace. + + See Also + -------- + Game.is_perfect_recall + """ + resolved_player = self._resolve_player(player, "has_perfect_recall") + return self.game.deref().HasPerfectRecall(resolved_player) + @property def min_payoff(self) -> decimal.Decimal | Rational: """The minimum payoff to any player in any play of the game. @@ -586,14 +934,11 @@ class Game: """ return rat_to_py(self.game.deref().GetMaxPayoff()) - @property - def subgames(self) -> GameSubgames: - """The set of subgames in the game. - - Iteration over this property yields the subgames in postorder - (children before parents). + def get_subgame_roots(self) -> list[tuple]: + """Returns the Histories of the roots of the subgames of the game, in + postorder (children before parents). - .. versionadded:: 16.7.0 + .. versionadded:: 17.0.0 Raises ------ @@ -602,42 +947,93 @@ class Game: """ if not self.is_tree: raise UndefinedOperationError( - "Operation only defined for games with a tree representation" + "get_subgame_roots(): operation only defined for games " + "with a tree representation" ) - return GameSubgames.wrap(self.game) + return [ + _history_of(Node.wrap(subgame.deref().GetRoot())) + for subgame in self.game.deref().GetSubgames() + ] + + def get_minimal_subgame(self, history: Selector) -> tuple: + """Returns the History of the root of the smallest subgame containing the + information set or event that the node identified by `history` belongs to. - def minimal_subgame(self, infoset: NodeReference) -> Subgame: - """Returns the smallest subgame containing `infoset`. + `history` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionadded:: 16.7.0 + .. versionchanged:: 17.0.0 + Renamed from `minimal_subgame`. `node` (formerly `infoset`) is now a + `Selector`; a `Node` or `str` is no longer accepted directly -- build + one with `H`. + .. versionchanged:: 17.0.0 + Returns the History of the subgame's root, instead of a `Subgame` object. Parameters ---------- - infoset : Node or str - A node belonging to the information set to query, or such a node's label. + history : Selector + A `Selector` resolving to a single node belonging to the information + set or event to query. Returns ------- - Subgame - The smallest subgame containing `infoset`. - - .. versionadded:: 16.7.0 + tuple + The History of the root of the smallest subgame containing the + information set or event that `history` belongs to. Raises ------ + TypeError + If `history` is not a `Selector`. UndefinedOperationError If the game does not have a tree representation. - MismatchError - If `infoset` is from a different game. + ValueError + If `history` does not resolve to exactly one node, or belongs to no + information set or event (it is terminal). """ if not self.is_tree: raise UndefinedOperationError( - "Operation only defined for games with a tree representation" + "get_minimal_subgame(): operation only defined for games " + "with a tree representation" ) - resolved_infoset = self._resolve_infoset_or_event(infoset, "minimal_subgame") - return Subgame.wrap( - self.game.deref().GetMinimalSubgame( - cython.cast(_InfosetOrEvent, resolved_infoset)._resolve() + if not isinstance(history, Selector): + raise TypeError( + "get_minimal_subgame(): history must be a Selector, " + f"not {history.__class__.__name__}" ) + resolved_node = self._resolve_infoset_or_event(history, "get_minimal_subgame") + subgame: c_GameSubgame = self.game.deref().GetMinimalSubgame( + cython.cast(Node, resolved_node)._infoset_handle() ) + return _history_of(Node.wrap(subgame.deref().GetRoot())) + + def get_strategy_unreachable(self) -> list[tuple]: + """Returns the Histories of the nodes that are not reachable by any pure + strategy profile. + + A node is considered reachable if there exists at least one pure + strategy profile where the resulting path of play passes through it. + In games with absent-mindedness, some nodes may be unreachable because + any path to them requires conflicting choices at the same information + set. + + .. versionadded:: 17.0.0 + + Raises + ------ + UndefinedOperationError + If the game does not have a tree representation. + """ + if not self.is_tree: + raise UndefinedOperationError( + "get_strategy_unreachable(): operation only defined for games " + "with a tree representation" + ) + return [ + _history_of(node) for node in self._all_nodes() + if not cython.cast(Node, node)._is_strategy_reachable() + ] def get_behavior(self, player: str, @@ -716,47 +1112,89 @@ class Game: deref(deref(psp).deref()).SetStrategy(handle) return psp - def get_outcome(self, contingency: typing.Mapping) -> Outcome: - """Returns the `Outcome` attached to a pure-strategy contingency. + def get_outcome(self, location) -> str | None: + """Returns the label of the outcome attached at `location`. + + For a tree game, `location` is a `Selector` (an `H`-built expression, + evaluated against this game) that must resolve to exactly one node. - Only defined for games in strategic (table) representation; for extensive-form - and action-graph games, a pure-strategy contingency has no single stored outcome - to return (see `get_payoffs`). + For a strategic (table) game, `location` is a pure-strategy + contingency -- a complete mapping from the game's players' labels to + the label of the strategy played by that player. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For a tree game, `location` may now be a `Selector`, returning + the outcome's label (or `None`) directly, rather than raising + `UndefinedOperationError`. + + .. versionchanged:: 17.0.0 + Always returns the outcome's label (or `None`); previously + returned the `Outcome` object itself for a strategic game. + Parameters ---------- - contingency : Mapping - A complete mapping from the game's players' labels to the label of the - strategy played by that player. + location : Selector or Mapping + A `Selector` resolving to a single node (tree game), or a + pure-strategy contingency (strategic game). Returns ------- - Outcome - The outcome attached to `contingency` (possibly the null outcome). + str or None + The label of the outcome attached at `location`, or `None` if it + is the null outcome. Raises ------ - UndefinedOperationError - If the game is not in strategic (table) representation. + TypeError + If `location` is not a `Selector` (tree game); or is not a + mapping, or a key or value is not a `str` (strategic game). ValueError - If `contingency` does not specify exactly one strategy for each player - of the game, or a key is an empty or all-whitespace string. + For a tree game, if `location` does not resolve to exactly one + node. For a strategic game, if `location` does not specify + exactly one strategy for each player of the game, or a key is an + empty or all-whitespace string. KeyError - If a player label, or a player's strategy label, does not match any - player, or that player's strategies, in the game. - TypeError - If `contingency` is not a mapping, or a key or value is not a `str`. + For a strategic game, if a player label, or a player's strategy + label, does not match any player, or that player's strategies, + in the game. + UndefinedOperationError + If the game is in neither a tree nor a strategic (table) + representation. + """ + if self.is_tree: + if not isinstance(location, Selector): + raise TypeError( + f"get_outcome(): location must be a Selector, not " + f"{location.__class__.__name__}" + ) + resolved_node = self._resolve_node(location, "get_outcome") + resolved_outcome: c_GameOutcome = ( + cython.cast(Node, resolved_node).node.deref().GetOutcome() + ) + else: + resolved_outcome = self._get_contingency_outcome(location, "get_outcome") + if resolved_outcome.deref().IsNull(): + return None + return resolved_outcome.deref().GetLabel().decode("utf-8") + + @cython.cfunc + def _get_contingency_outcome( + self, contingency: typing.Mapping, funcname: str + ) -> c_GameOutcome: + """Resolve the outcome attached at a pure-strategy `contingency` in a + strategic (table) game, as a raw C++ handle. Not part of the public API; + used internally by `get_outcome`, `from_arrays`, and `from_dict`. """ - if self.is_tree or self.game.deref().IsAgg(): + if self.game.deref().IsAgg(): raise UndefinedOperationError( - "get_outcome(): operation not defined for games not in " - "strategic (table) representation" + f"{funcname}(): operation not defined for games not in " + f"strategic (table) representation" ) - resolved = self._resolve_contingency(contingency, "get_outcome") + resolved = self._resolve_contingency(contingency, funcname) psp = self._make_pure_strategy_profile(resolved) - return Outcome.wrap(deref(deref(psp).deref()).GetOutcome()) + return deref(deref(psp).deref()).GetOutcome() def get_payoffs(self, contingency: typing.Mapping) -> PayoffVector: """Returns the payoff to each player at a pure-strategy contingency. @@ -911,19 +1349,20 @@ class Game: if len(data) != len(self.players): raise ValueError("Number of elements does not match number of players") for (p, d) in zip(self.players, data): - p_infosets = self.get_infosets(p) + p_infosets = self._get_infosets(p) if len(p_infosets) != len(d): raise ValueError(f"Number of elements does not match number of infosets for {p}") for (node, v) in zip(p_infosets, d, strict=True): - infoset = node.infoset - if len(infoset.actions) != len(v): + if len(node.actions) != len(v): raise ValueError( f"Number of elements does not match number of " - f"actions for infoset {infoset} for {p}" + f"actions for infoset {node} for {p}" ) - profile[node] = { - a: typefunc(u) for a, u in zip(infoset.actions, v, strict=True) - } + profile._setprob_infoset( + node, + {a: typefunc(u) for a, u in zip(node.actions, v, strict=True)}, + sparse=True, + ) return profile def mixed_behavior_profile(self, data=None, rational=False) -> MixedBehaviorProfile: @@ -1002,16 +1441,20 @@ class Game: if denom is None: profile = self.mixed_behavior_profile() for player in self.players: - for node in self.get_infosets(player): - profile[node] = _dirichlet_distribution(node.infoset.actions, gen) + for node in self._get_infosets(player): + profile._setprob_infoset( + node, _dirichlet_distribution(node.actions, gen), sparse=True + ) return profile elif denom < 1: raise ValueError("random_behavior_profile(): denom must be positive") else: profile = self.mixed_behavior_profile(rational=True) for player in self.players: - for node in self.get_infosets(player): - profile[node] = _grid_distribution(node.infoset.actions, denom, gen) + for node in self._get_infosets(player): + profile._setprob_infoset( + node, _grid_distribution(node.actions, denom, gen), sparse=True + ) return profile def strategy_support_profile( @@ -1051,9 +1494,13 @@ class Game: ---------- actions : function, optional By default the support profile contains all actions at all information - sets. If specified, called as ``actions(node, action)`` for each action at - each information set, where ``node`` is a representative node of the - information set; only actions for which it returns `True` are included. + sets. If specified, called as ``actions(history, action)`` for each action + at each information set, where ``history`` is a read-only `HistoryView` (see + `Selector.filter`) of a representative member of the information set; only + actions for which it returns `True` are included. + + .. versionchanged:: 17.0.0 + ``actions`` is now called with a `HistoryView` rather than a `Node`. Returns ------- @@ -1062,10 +1509,12 @@ class Game: profile = BehaviorSupportProfile.wrap(make_shared[c_BehaviorSupportProfile](self.game)) if actions is not None: for player in self.players: - for node in self.get_infosets(player): - infoset_handle: c_GameInfoset = cython.cast(Infoset, node.infoset)._resolve() + for node in self._get_infosets(player): + history_view = HistoryView._wrap(node, _history_of(node)) + infoset_handle: c_GameInfoset = cython.cast(Node, node)._infoset_handle() for action in infoset_handle.deref().GetActions(): - if not actions(node, action.deref().GetLabel().decode("utf-8")): + label = action.deref().GetLabel().decode("utf-8") + if not actions(history_view, label): if not deref(profile.profile).RemoveAction(action): raise ValueError( "attempted to remove the last action at an information set" @@ -1254,8 +1703,40 @@ class Game: f"{funcname}(): player '{player}' has no strategy with label '{label}'" ) + @cython.cfunc + def _resolve_outcome( + self, label: typing.Any, funcname: str, argname: str = "label" + ) -> c_GameOutcome: + """Resolve `label` to the C++ handle of one of the game's outcomes. + + Not part of the public API -- used internally to bridge an outcome label to + the underlying C++ object without ever constructing a Python wrapper for it. + + Raises + ------ + KeyError + If no outcome has label `label`. + TypeError + If `label` is not a `str`. + ValueError + If `label` is an empty string or all spaces. + """ + if not isinstance(label, str): + raise TypeError( + f"{funcname}(): {argname} must be str, not {label.__class__.__name__}" + ) + if not label.strip(): + raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") + for outcome in self.game.deref().GetOutcomes(): + if outcome.deref().GetLabel().decode("utf-8") == label: + return outcome + raise KeyError(f"{funcname}(): no outcome with label '{label}'") + def _resolve_node(self, node: typing.Any, funcname: str, argname: str = "node") -> Node: - """Resolve an attempt to reference a node of the game. + """Resolve an attempt to reference a node of the game. A bare `Node` is not + accepted -- every public method that reaches this already requires a + `Selector` (or, for internal callers, an already-resolved `Node`, never + routed back through here). Parameters ---------- @@ -1268,45 +1749,54 @@ class Game: Raises ------ - MismatchError - If `node` is a `Node` from a different game. KeyError If `node` is a string and no node in the game has that label. TypeError - If `node` is not a `Node` or a `str` + If `node` is not a `Selector`, `tuple`, or `str` ValueError If `node` is an empty `str` or all spaces """ - if isinstance(node, Node): - if node.game != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return node + if isinstance(node, Selector): + resolved = self._get_nodes(node) + if len(resolved) != 1: + raise ValueError( + f"{funcname}(): {argname} selector must resolve to exactly one " + f"node, resolved to {len(resolved)}" + ) + return resolved[0] + elif isinstance(node, tuple): + # A History -- the manual fallback: root-anchored, every step exact. + return self._resolve_node(Selector().path(*node), funcname, argname) elif isinstance(node, str): if not node.strip(): raise ValueError( f"{funcname}(): {argname} cannot be an empty string or all spaces" ) - for n in self.nodes: - if n.label == node: + for n in self._all_nodes(): + if cython.cast(Node, n)._label == node: return n raise KeyError(f"{funcname}(): no node with label '{node}'") raise TypeError( - f"{funcname}(): {argname} must be Node or str, not {node.__class__.__name__}" + f"{funcname}(): {argname} must be Selector, tuple, or str, " + f"not {node.__class__.__name__}" ) def _resolve_nodes(self, nodes: typing.Any, funcname: str, argname: str = "nodes") -> list[Node]: - """Resolve an attempt to reference a subset of the nodes of the game of the game. + """Resolve an attempt to reference a subset of the nodes of the game. - See `_resolve_node` for details on functionality. + `nodes` is a `Selector` (an `H`-built expression), evaluated against this + game via `_get_nodes`; or an already-resolved list of `Node`, dispatched + internally one group at a time from a `GroupedSelector` -- never a bare + `Node`/History/label from the caller directly, so no further per-element + resolution is needed. """ - resolved_nodes = [ - self._resolve_node(n, funcname, argname) - for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, str) - else [nodes]) - ] + if isinstance(nodes, Selector): + resolved_nodes = self._get_nodes(nodes) + else: + resolved_nodes = list(nodes) if not resolved_nodes: raise ValueError(f"{funcname}(): `{argname}` must not be empty") if len(resolved_nodes) != len(set(resolved_nodes)): @@ -1314,83 +1804,97 @@ class Game: return resolved_nodes def _resolve_infoset(self, - infoset: typing.Any, funcname: str, argname: str = "infoset") -> Infoset: + infoset: typing.Any, funcname: str, argname: str = "infoset") -> Node: """Resolve an attempt to reference a personal player's information set of the - game, via a member node or its label. + game, via a `Selector` resolving to a member node, or such a node's label. Parameters ---------- - infoset : Node or str - A node belonging to the information set, or such a node's label. + infoset : Selector, tuple, or str + A `Selector`/History resolving to a node belonging to the information + set, or such a node's label. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'infoset' The name of the argument being checked - Raises + Returns + ------- + Node + The resolved node itself, validated as currently belonging to a personal + player's information set. + + Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. KeyError If `infoset` is a string and no node in the game has that label. TypeError - If `infoset` is not a `Node` or a `str` + If `infoset` is not a `Selector`, `tuple`, or `str` ValueError If `infoset` resolves to a chance event rather than a personal player's information set, or to no information set at all (the node is terminal). """ resolved_node = self._resolve_node(infoset, funcname, argname) - return cython.cast(Infoset, _resolve_infoset_or_event_kind( - resolved_node.infoset, resolved_node.event, + is_personal, is_chance = _node_infoset_kind(resolved_node) + return _resolve_infoset_or_event_kind( + resolved_node, is_personal, is_chance, "information set", "a personal player's information set", "a chance event", funcname, argname - )) + ) def _resolve_event(self, - event: typing.Any, funcname: str, argname: str = "event") -> Event: - """Resolve an attempt to reference a chance event of the game, via a member - node or its label. + event: typing.Any, funcname: str, argname: str = "event") -> Node: + """Resolve an attempt to reference a chance event of the game, via a + `Selector` resolving to a member node, or such a node's label. Parameters ---------- - event : Node or str - A node belonging to the event, or such a node's label. + event : Selector, tuple, or str + A `Selector`/History resolving to a node belonging to the event, or + such a node's label. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'event' The name of the argument being checked + Returns + ------- + Node + The resolved node itself, validated as currently belonging to a chance + event. + Raises ------ - MismatchError - If `event` is a `Node` from a different game. KeyError If `event` is a string and no node in the game has that label. TypeError - If `event` is not a `Node` or a `str` + If `event` is not a `Selector`, `tuple`, or `str` ValueError If `event` resolves to a personal player's information set rather than a chance event, or to no event at all (the node is terminal). """ resolved_node = self._resolve_node(event, funcname, argname) - return cython.cast(Event, _resolve_infoset_or_event_kind( - resolved_node.event, resolved_node.infoset, + is_personal, is_chance = _node_infoset_kind(resolved_node) + return _resolve_infoset_or_event_kind( + resolved_node, is_chance, is_personal, "event", "a chance event", "a personal player's information set", funcname, argname - )) + ) def _resolve_infoset_or_event(self, infoset: typing.Any, funcname: str, - argname: str = "infoset") -> typing.Any: + argname: str = "infoset") -> Node: """Resolve an attempt to reference an information set or event of the game - (whichever applies), via a member node or its label. For operations that - apply uniformly to either, such as attaching to an existing one. + (whichever applies), via a `Selector` resolving to a member node, or such a + node's label. For operations that apply uniformly to either, such as + attaching to an existing one. Parameters ---------- - infoset : Node or str - A node belonging to the information set or event, or such a node's label. + infoset : Selector, tuple, or str + A `Selector`/History resolving to a node belonging to the information + set or event, or such a node's label. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'infoset' @@ -1398,27 +1902,24 @@ class Game: Returns ------- - Infoset or Event + Node + The resolved node itself, validated as currently belonging to some + information set or event. Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. KeyError If `infoset` is a string and no node in the game has that label. TypeError - If `infoset` is not a `Node` or a `str` + If `infoset` is not a `Selector`, `tuple`, or `str` ValueError If `infoset` resolves to no information set or event (the node is terminal). """ resolved_node = self._resolve_node(infoset, funcname, argname) - resolved_infoset = cython.cast(Infoset, resolved_node.infoset) - if resolved_infoset: - return resolved_infoset - resolved_event = cython.cast(Event, resolved_node.event) - if resolved_event: - return resolved_event + is_personal, is_chance = _node_infoset_kind(resolved_node) + if is_personal or is_chance: + return resolved_node raise ValueError( f"{funcname}(): {argname} resolves to no information set " f"(the node is terminal)" @@ -1444,7 +1945,7 @@ class Game: raise IndexError(f"{funcname}(): must specify exactly one probability per action") return probs - def append_move(self, nodes: Node | NodeReferenceSet, + def append_move(self, nodes: Selector | GroupedSelector, player: str, actions: list[str]) -> None: """Add a move for `player` at terminal `nodes`. All elements of `nodes` become part of @@ -1452,18 +1953,45 @@ class Game: `player` must be a personal player; use `append_event` to add a chance move. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression) -- in the latter case, one new information set is + created per distinct group, rather than one spanning every match. + + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`. UndefinedOperationError If `nodes` are not all terminal, or `actions` is empty. - MismatchError - If an element from `nodes` is a `Node` from a different game. KeyError If no player in the game has label `player`. ValueError If `nodes` has duplicated elements, or is empty; or if `actions` contains an empty or a duplicated label. """ + if isinstance(nodes, GroupedSelector): + for group in self._group_nodes(nodes).values(): + if not group: + continue + self._append_move_at(group, player, actions) + return + if not isinstance(nodes, Selector): + raise TypeError( + f"append_move(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + self._append_move_at(nodes, player, actions) + + def _append_move_at(self, nodes: Selector | list[Node], player: str, + actions: list[str]) -> None: + """Internal: shared body of `append_move`, taking either a `Selector` or an + already-resolved list of `Node` (the latter used for one group at a time, + dispatched from a `GroupedSelector`).""" resolved_player = self._resolve_player(player, "append_move") if not actions: raise UndefinedOperationError("append_move(): `actions` must be a nonempty list") @@ -1472,7 +2000,7 @@ class Game: if len(set(actions)) != len(actions): raise ValueError("append_move(): action labels must be unique") resolved_nodes = self._resolve_nodes(nodes, "append_move", "nodes") - if any(len(n.children) > 0 for n in resolved_nodes): + if any(not cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError("append_move(): `nodes` must be terminal nodes") resolved_node = cython.cast(Node, resolved_nodes[0]) @@ -1480,118 +2008,182 @@ class Game: for label in actions: c_actions.push_back(label.encode("utf-8")) self.game.deref().AppendMove(resolved_node.node, resolved_player, c_actions) - resolved_infoset = cython.cast(Infoset, resolved_node.infoset) + infoset_handle: c_GameInfoset = resolved_node._infoset_handle() for n in resolved_nodes[1:]: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) + self.game.deref().AppendMove(cython.cast(Node, n).node, infoset_handle) + + def append_infoset(self, nodes: Selector | GroupedSelector, + infoset: Selector) -> None: + """Add a move at terminal `nodes`, joining the information set that the node + identified by `infoset` belongs to. + + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together -- every resolved + node joins the same `infoset` regardless of grouping). - def append_infoset(self, nodes: Node | NodeReferenceSet, - infoset: NodeReference) -> None: - """Add a move in the information set or event `infoset` at terminal `nodes`. + `infoset` is a `Selector` that must resolve to exactly one node; that node + must belong to a personal player and must not be terminal -- the information + set it currently belongs to is the one joined. + + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`, and `infoset` is now a + `Selector` identifying a node by the information set it belongs to, + rather than a `Node` or `str` reference to an `Infoset`/`Event` directly. + Joining an existing chance event is no longer supported here. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of terminal nodes at which to add the move. - infoset : Node or str - A node belonging to the information set or event to join, or such a - node's label. + infoset : Selector + A `Selector` resolving to a single node of the personal player's + information set to join. Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`, or `infoset` is not + a `Selector`. UndefinedOperationError - If any element in `nodes` is not a terminal node. - MismatchError - If an element in `nodes` is a `Node` from a different game, - or `infoset` is a `Node` from a different game. + If any element in `nodes` is not a terminal node, or `infoset` resolves + to a terminal node or to a chance node. ValueError - If `nodes` has duplicated elements, or is empty. + If `nodes` has duplicated elements, or is empty; or if `infoset` does not + resolve to exactly one node. """ - resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "append_infoset") - ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"append_infoset(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + if not isinstance(infoset, Selector): + raise TypeError( + f"append_infoset(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) + infoset_node = cython.cast(Node, self._resolve_node(infoset, "append_infoset", "infoset")) + is_personal, _ = _node_infoset_kind(infoset_node) + if not is_personal: + raise UndefinedOperationError( + "append_infoset(): infoset must resolve to a personal player's node" + ) + infoset_handle: c_GameInfoset = infoset_node._infoset_handle() resolved_nodes = self._resolve_nodes(nodes, "append_infoset", "nodes") - if any(len(n.children) > 0 for n in resolved_nodes): + if any(not cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError("append_infoset(): `nodes` must be terminal nodes") for n in resolved_nodes: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) + self.game.deref().AppendMove(cython.cast(Node, n).node, infoset_handle) + + def append_event(self, nodes: Selector | GroupedSelector, + actions: typing.Mapping) -> None: + """Add a chance move at terminal `nodes`, with actions and their probabilities + given by `actions`. All elements of `nodes` become part of a new event. - def append_event(self, nodes: Node | NodeReferenceSet, - actions: list[str], - probs: typing.Sequence | typing.Mapping) -> None: - """Add a chance move at terminal `nodes`, with distribution `probs`. All elements - of `nodes` become part of a new event, with actions labeled according to `actions`. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression) -- in the latter case, one new event is created per + distinct group, rather than one spanning every match. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `actions` and `probs` are combined into a single mapping from action + label to probability, rather than a list of labels plus a separate + probability sequence or mapping. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of terminal nodes at which to add the move. - actions : list of str - The labels of the actions of the new event. Nonempty, with no empty or - duplicated label. - probs : sequence or mapping - The probability distribution over `actions`. A sequence must specify one - probability per action, in the order given in `actions`. A mapping from - action labels to probabilities may be sparse; omitted actions are assigned - probability zero. Probabilities are non-negative and sum to exactly one. + actions : Mapping + A mapping from each new action's label to its probability. Nonempty, + with no empty label. Probabilities are non-negative and sum to exactly + one. Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`. UndefinedOperationError If `nodes` are not all terminal, or `actions` is empty. - MismatchError - If an element from `nodes` is a `Node` from a different game. - KeyError - If a key of `probs` matches no label in `actions`. - IndexError - If a sequence `probs` does not have exactly one entry per action. ValueError If `nodes` has duplicated elements, or is empty; if `actions` contains - an empty or a duplicated label; or if `probs` are not non-negative numbers + an empty label; or if the probabilities are not non-negative numbers summing to exactly one. """ - if not actions: - raise UndefinedOperationError("append_event(): `actions` must be a nonempty list") - if any(not label for label in actions): + if isinstance(nodes, GroupedSelector): + for group in self._group_nodes(nodes).values(): + if not group: + continue + self._append_event_at(group, actions) + return + if not isinstance(nodes, Selector): + raise TypeError( + f"append_event(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + self._append_event_at(nodes, actions) + + def _append_event_at(self, nodes: Selector | list[Node], actions: typing.Mapping) -> None: + """Internal: shared body of `append_event`, taking either a `Selector` or an + already-resolved list of `Node` (the latter used for one group at a time, + dispatched from a `GroupedSelector`).""" + action_labels = list(actions) + if not action_labels: + raise UndefinedOperationError("append_event(): `actions` must be a nonempty mapping") + if any(not label for label in action_labels): raise ValueError("append_event(): action labels must not be empty") - if len(set(actions)) != len(actions): - raise ValueError("append_event(): action labels must be unique") resolved_nodes = self._resolve_nodes(nodes, "append_event", "nodes") - if any(len(n.children) > 0 for n in resolved_nodes): + if any(not cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError("append_event(): `nodes` must be terminal nodes") - resolved_probs = self._resolve_probs(probs, actions, "append_event") resolved_node = cython.cast(Node, resolved_nodes[0]) c_actions = stdvector[string]() - for label in actions: + for label in action_labels: c_actions.push_back(label.encode("utf-8")) c_probs = stdvector[c_Number]() - for p in resolved_probs: - c_probs.push_back(_to_number(p)) + for label in action_labels: + c_probs.push_back(_to_number(actions[label])) self.game.deref().AppendEvent(resolved_node.node, c_actions, c_probs) - resolved_event = cython.cast(Event, resolved_node.event) + event_handle: c_GameInfoset = resolved_node._infoset_handle() for n in resolved_nodes[1:]: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_event._resolve()) + self.game.deref().AppendMove(cython.cast(Node, n).node, event_handle) - def insert_move(self, node: Node | str, + def insert_move(self, node: Selector, player: str, actions: list[str]) -> None: - """Insert a move for `player` prior to the node `node`, with actions labeled - according to `actions`. `node` becomes the first child of the newly-inserted node. + """Insert a move for `player` prior to the node identified by `node`, with + actions labeled according to `actions`. The node becomes the first child of + the newly-inserted node. `player` must be a personal player; use `insert_event` to insert a chance move. + `node` is a `Selector` (an `H`-built expression, evaluated against this game) + that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If `actions` is empty. - MismatchError - If `node` is a `Node` from a different game. KeyError If no player in the game has label `player`. ValueError - If `actions` contains an empty or a duplicated label. + If `node` does not resolve to exactly one node, or `actions` contains an + empty or a duplicated label. """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_move(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_move")) resolved_player = self._resolve_player(player, "insert_move") if not actions: @@ -1605,85 +2197,105 @@ class Game: c_actions.push_back(label.encode("utf-8")) self.game.deref().InsertMove(resolved_node.node, resolved_player, c_actions) - def insert_infoset(self, node: Node | str, - infoset: NodeReference) -> None: - """Insert a move in the information set or event `infoset` prior to the node - `node`. `node` becomes the first child of the newly-inserted node. + def insert_infoset(self, node: Selector, + infoset: Selector) -> None: + """Insert a move in the information set or event that the node identified by + `infoset` belongs to, prior to the node identified by `node`. The node + becomes the first child of the newly-inserted node. - Parameters - ---------- - node : Node or str - The node before which to insert the move. - infoset : Node or str - A node belonging to the information set or event to join, or such a - node's label. + `node` and `infoset` are each a `Selector` (an `H`-built expression, + evaluated against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector` identifying a node by the information set + or event it belongs to, rather than a `Node` or `str` reference to an + `Infoset`/`Event` directly. Raises ------ - MismatchError - If `node` is a `Node` from a different game, or `infoset` is a `Node` from a - different game. + TypeError + If `node` or `infoset` is not a `Selector`. + ValueError + If `node` or `infoset` does not resolve to exactly one node, or if the + node identified by `infoset` belongs to no information set or event (it + is terminal). """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_infoset(): node must be a Selector, not {node.__class__.__name__}" + ) + if not isinstance(infoset, Selector): + raise TypeError( + f"insert_infoset(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_infoset")) - resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "insert_infoset") + resolved_infoset_node = cython.cast( + Node, self._resolve_infoset_or_event(infoset, "insert_infoset") ) - self.game.deref().InsertMove(resolved_node.node, resolved_infoset._resolve()) + self.game.deref().InsertMove(resolved_node.node, resolved_infoset_node._infoset_handle()) + + def insert_event(self, node: Selector, actions: typing.Mapping) -> None: + """Insert a chance move prior to the node identified by `node`, with actions + and their probabilities given by `actions`. The node becomes the first + child of the newly-inserted node. - def insert_event(self, node: Node | str, - actions: list[str], - probs: typing.Sequence | typing.Mapping) -> None: - """Insert a chance move prior to the node `node`, with actions labeled according - to `actions` and distribution `probs`. `node` becomes the first child of the - newly-inserted node. + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `actions` and `probs` are combined into a single mapping from action + label to probability, rather than a list of labels plus a separate + probability sequence or mapping. Parameters ---------- - node : Node or str - The node before which to insert the move. - actions : list of str - The labels of the actions of the new event. Nonempty, with no empty or - duplicated label. - probs : sequence or mapping - The probability distribution over `actions`. A sequence must specify one - probability per action, in the order given in `actions`. A mapping from - action labels to probabilities may be sparse; omitted actions are assigned - probability zero. Probabilities are non-negative and sum to exactly one. + node : Selector + A `Selector` resolving to the single node before which to insert the + move. + actions : Mapping + A mapping from each new action's label to its probability. Nonempty, + with no empty label. Probabilities are non-negative and sum to exactly + one. Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If `actions` is empty. - MismatchError - If `node` is a `Node` from a different game. - KeyError - If a key of `probs` matches no label in `actions`. - IndexError - If a sequence `probs` does not have exactly one entry per action. ValueError - If `actions` contains an empty or a duplicated label, or if `probs` are not - non-negative numbers summing to exactly one. + If `node` does not resolve to exactly one node; if `actions` contains + an empty label; or if the probabilities are not non-negative numbers + summing to exactly one. """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_event(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_event")) - if not actions: - raise UndefinedOperationError("insert_event(): `actions` must be a nonempty list") - if any(not label for label in actions): + action_labels = list(actions) + if not action_labels: + raise UndefinedOperationError("insert_event(): `actions` must be a nonempty mapping") + if any(not label for label in action_labels): raise ValueError("insert_event(): action labels must not be empty") - if len(set(actions)) != len(actions): - raise ValueError("insert_event(): action labels must be unique") - resolved_probs = self._resolve_probs(probs, actions, "insert_event") c_actions = stdvector[string]() - for label in actions: + for label in action_labels: c_actions.push_back(label.encode("utf-8")) c_probs = stdvector[c_Number]() - for p in resolved_probs: - c_probs.push_back(_to_number(p)) + for label in action_labels: + c_probs.push_back(_to_number(actions[label])) self.game.deref().InsertEvent(resolved_node.node, c_actions, c_probs) - def copy_tree(self, src: Node | str, dest: Node | str) -> None: - """Copy the subtree rooted at the node `src` to the node `dest`. + def copy_tree(self, src: Selector, dest: Selector) -> None: + """Copy the subtree rooted at the node identified by `src` to the node + identified by `dest`. Each node in the subtree copied to follow `dest` is placed in the same information set as the corresponding node in the original subtree under `src`. @@ -1694,93 +2306,152 @@ class Game: The outcome associated with `dest` is not changed by this operation. + `src` and `dest` are each a `Selector` (an `H`-built expression, evaluated + against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `src` and `dest` are now `Selector`s; a `Node` or `str` is no longer + accepted directly -- build one with `H`. + Parameters ---------- - src : Node or str - The root of the source subtree to copy - dest : Node or str - The destination subtree to copy to. `dest` must be a terminal node. + src : Selector + A `Selector` resolving to the root of the source subtree to copy. + dest : Selector + A `Selector` resolving to the destination subtree to copy to. Must + resolve to a terminal node. Raises ------ - MismatchError - If `src` or `dest` is not a member of the same game as this node. + TypeError + If `src` or `dest` is not a `Selector`. UndefinedOperationError If `dest` is not a terminal node. + ValueError + If `src` or `dest` does not resolve to exactly one node. """ + if not isinstance(src, Selector): + raise TypeError(f"copy_tree(): src must be a Selector, not {src.__class__.__name__}") + if not isinstance(dest, Selector): + raise TypeError( + f"copy_tree(): dest must be a Selector, not {dest.__class__.__name__}" + ) resolved_src = cython.cast(Node, self._resolve_node(src, "copy_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "copy_tree", "dest")) - if not resolved_dest.is_terminal: + if not cython.cast(Node, resolved_dest)._is_terminal(): raise UndefinedOperationError("copy_tree(): `dest` must be a terminal node.") self.game.deref().CopyTree(resolved_dest.node, resolved_src.node) - def move_tree(self, src: Node | str, dest: Node | str) -> None: - """Move the subtree rooted at 'src' to 'dest'. + def move_tree(self, src: Selector, dest: Selector) -> None: + """Move the subtree rooted at the node identified by `src` to the node + identified by `dest`. + + `src` and `dest` are each a `Selector` (an `H`-built expression, evaluated + against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `src` and `dest` are now `Selector`s; a `Node` or `str` is no longer + accepted directly -- build one with `H`. Parameters ---------- - src : Node or str - The root of the source subtree to move - dest : Node or str - The destination subtree to move to. `dest` must be a terminal node. + src : Selector + A `Selector` resolving to the root of the source subtree to move. + dest : Selector + A `Selector` resolving to the destination subtree to move to. Must + resolve to a terminal node. Raises ------ - MismatchError - If `src` or `dest` is not a member of the same game as this node. + TypeError + If `src` or `dest` is not a `Selector`. UndefinedOperationError If `dest` is not a terminal node, or `dest` is a successor of `src`. + ValueError + If `src` or `dest` does not resolve to exactly one node. """ + if not isinstance(src, Selector): + raise TypeError(f"move_tree(): src must be a Selector, not {src.__class__.__name__}") + if not isinstance(dest, Selector): + raise TypeError( + f"move_tree(): dest must be a Selector, not {dest.__class__.__name__}" + ) resolved_src = cython.cast(Node, self._resolve_node(src, "move_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "move_tree", "dest")) - if not resolved_dest.is_terminal: + if not cython.cast(Node, resolved_dest)._is_terminal(): raise UndefinedOperationError("move_tree(): `dest` must be a terminal node.") - if resolved_dest.is_successor_of(resolved_src): + if resolved_dest._is_successor_of(resolved_src): raise UndefinedOperationError("move_tree(): `dest` cannot be a successor of `src`.") self.game.deref().MoveTree(resolved_dest.node, resolved_src.node) - def delete_parent(self, node: Node | str) -> None: - """Delete the parent node of `node`. `node` replaces its parent in the tree. All other - subtrees rooted at `node`'s parent are deleted. + def delete_parent(self, node: Selector) -> None: + """Delete the parent of the node identified by `node`. That node replaces + its parent in the tree. All other subtrees rooted at the parent are deleted. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - node : Node or str - The node to retain after deleting its parent. - If a string is passed, the node is determined by finding the node with that label, - if any. + node : Selector + A `Selector` resolving to the single node to retain after deleting its + parent. Raises ------ - MismatchError - If `node` is a `Node` from a different game. + TypeError + If `node` is not a `Selector`. + ValueError + If `node` does not resolve to exactly one node. """ + if not isinstance(node, Selector): + raise TypeError( + f"delete_parent(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "delete_parent")) self.game.deref().DeleteParent(resolved_node.node) - def delete_tree(self, node: Node | str) -> None: - """Truncate the game tree at `node`, deleting the subtree beneath it. + def delete_tree(self, node: Selector) -> None: + """Truncate the game tree at the node identified by `node`, deleting the + subtree beneath it. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - node : Node or str - The node to truncate the game at. If a string is passed, the node is determined by - finding the node with that label, if any. + node : Selector + A `Selector` resolving to the single node to truncate the game at. Raises ------ - MismatchError - If `node` is a `Node` from a different game. + TypeError + If `node` is not a `Selector`. + ValueError + If `node` does not resolve to exactly one node. """ + if not isinstance(node, Selector): + raise TypeError( + f"delete_tree(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "delete_tree")) self.game.deref().DeleteTree(resolved_node.node) def set_move_actions(self, - infoset: NodeReference, + infoset: Selector, actions: list[str], drop: bool = False, add: bool = True) -> None: - """Set the actions at the move `infoset` to be `actions`, matching by label. + """Set the actions at the move that the node identified by `infoset` + belongs to, to be `actions`, matching by label. An entry of `actions` matching the label of a current action refers to that action, which keeps its subtrees; an entry matching no current action creates a new action there, @@ -1788,13 +2459,19 @@ class Game: in `actions` is deleted, along with the subtrees its branches lead to. Listing the current labels in a new order reorders the actions as well as the children. + `infoset` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the (personal player's) move at which to set the - actions, or such a node's label. + infoset : Selector + A `Selector` resolving to a single node belonging to the (personal + player's) move at which to set the actions. actions : list of str The labels of the actions the move is to have, in order. Must be nonempty and without duplicates; each label must be a valid, nonempty label. @@ -1807,26 +2484,28 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If `infoset` is a string matching no node. TypeError - If `actions` is a string, or not an iterable of strings. + If `infoset` is not a `Selector`; or if `actions` is a string, or not + an iterable of strings. UndefinedOperationError If `actions` is empty. ValueError - If `infoset` resolves to an event rather than a personal player's move - (use `set_event_actions` for an event); or if a label in `actions` is - repeated, empty, or invalid; or if adding or deleting actions is not - confirmed by `add`/`drop`. + If `infoset` does not resolve to exactly one node, or resolves to an + event rather than a personal player's move (use `set_event_actions` + for an event); or if a label in `actions` is repeated, empty, or + invalid; or if adding or deleting actions is not confirmed by + `add`/`drop`. See Also -------- set_event_actions : The corresponding operation for the actions of an event. relabel_actions : Change the labels of actions, leaving the tree unchanged. """ - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_move_actions")) + if not isinstance(infoset, Selector): + raise TypeError( + f"set_move_actions(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) + resolved_infoset = cython.cast(Node, self._resolve_infoset(infoset, "set_move_actions")) if isinstance(actions, str) or not hasattr(actions, "__iter__"): raise TypeError("set_move_actions(): actions must be an iterable of str") labels = list(actions) @@ -1842,15 +2521,16 @@ class Game: c_labels = stdvector[string]() for label in labels: c_labels.push_back(label.encode("utf-8")) - self.game.deref().SetMoveActions(resolved_infoset._resolve(), c_labels) + self.game.deref().SetMoveActions(resolved_infoset._infoset_handle(), c_labels) def set_event_actions(self, - event: NodeReference, + event: Selector, probs: typing.Mapping, drop: bool = False, add: bool = True) -> None: - """Set the actions at the event `event` to be the keys of `probs`, in order, - with the given probability distribution. + """Set the actions at the event that the node identified by `event` + belongs to, to be the keys of `probs`, in order, with the given + probability distribution. A key of `probs` matching the label of a current action refers to that action, which keeps its subtrees; a key matching no current action creates a new action @@ -1863,13 +2543,19 @@ class Game: of the operation, rather than inferred from the actions which remain: there is no way to reorder an event's actions without also restating their probabilities. + `event` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `event` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - event : Node or str - A node belonging to the event at which to set the actions, or such a - node's label. + event : Selector + A `Selector` resolving to a single node belonging to the event at + which to set the actions. probs : dict-like A mapping from the label of each action the event is to have, in order, to its probability. Must be nonempty, with valid, nonempty keys. Values must be @@ -1883,20 +2569,18 @@ class Game: Raises ------ - MismatchError - If `event` is a `Node` from a different game. - KeyError - If `event` is a string matching no node. TypeError - If `probs` is not a mapping, or a key of `probs` is not a string. + If `event` is not a `Selector`; or if `probs` is not a mapping, or a + key of `probs` is not a string. UndefinedOperationError If `probs` is empty, or if `event` resolves to a personal player's information set rather than an event; use `set_move_actions` for a personal player's move. ValueError - If a key of `probs` is empty or invalid; if adding or deleting actions is not - confirmed by `add`/`drop`; or if the values of `probs` are not non-negative - numbers summing to exactly one. + If `event` does not resolve to exactly one node; if a key of `probs` + is empty or invalid; if adding or deleting actions is not confirmed by + `add`/`drop`; or if the values of `probs` are not non-negative numbers + summing to exactly one. See Also -------- @@ -1904,7 +2588,11 @@ class Game: player's move. relabel_actions : Change the labels of actions, leaving the tree unchanged. """ - resolved_event = cython.cast(Event, self._resolve_event(event, "set_event_actions")) + if not isinstance(event, Selector): + raise TypeError( + f"set_event_actions(): event must be a Selector, not {event.__class__.__name__}" + ) + resolved_event = cython.cast(Node, self._resolve_event(event, "set_event_actions")) if not isinstance(probs, typing.Mapping): raise TypeError( "set_event_actions(): probs must be a mapping from label to probability" @@ -1926,11 +2614,11 @@ class Game: for label in labels: c_labels.push_back(label.encode("utf-8")) c_probs.push_back(_to_number(probs[label])) - self.game.deref().SetEventActions(resolved_event._resolve(), c_labels, c_probs) + self.game.deref().SetEventActions(resolved_event._infoset_handle(), c_labels, c_probs) def make_event(self, - nodes: Node | NodeReferenceSet, - probs: typing.Sequence | typing.Mapping, + nodes: Selector | GroupedSelector, + probs: typing.Mapping, label: str | None = None) -> None: """Form `nodes` into a single event with distribution `probs`. @@ -1939,24 +2627,34 @@ class Game: converted, and the move is thereafter resolved by chance. Nodes are removed from whatever information sets or events they currently belong to; any of those which retain members survive, keeping their labels, and those left with no members are deleted. - Any ``Infoset`` object referring to a deleted one becomes invalid, and subsequent use - raises ``RuntimeError``. - The resulting event is accessible as ``node.event`` for any node in `nodes`. + The resulting event's members, actions, and player are accessible via + ``Node.members``/``Node.actions``/``Node.player`` for any node in `nodes`. - The first node in `nodes` determines the action order of the event, - and is the frame against which mapping keys in `probs` are resolved. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together into the one event). + + Which resolved node is treated as "first", determining the action order of + the event and the frame against which keys of `probs` are resolved, follows + `nodes`' own resolution order. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `probs` is now always a mapping from action label to probability; a + positional sequence is no longer accepted. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of nonterminal nodes to place in the event. - probs : sequence or mapping - The probability distribution over the actions of the event. A sequence must specify - one probability per action, in action order. A mapping from action labels - to probabilities may be sparse; omitted actions are assigned probability zero. - Probabilities are non-negative and sum to exactly one. + probs : Mapping + The probability distribution over the actions of the event, as a mapping + from action label to probability. May be sparse; omitted actions are + assigned probability zero. Probabilities are non-negative and sum to + exactly one. label : str, optional The label of the new event. If specified, must be unique among the events of the game after the operation. A label currently held by another event @@ -1964,13 +2662,11 @@ class Game: Raises ------ - MismatchError - If any of `nodes` is from a different game. + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`, or `probs` is not a + mapping. KeyError - If a node reference matches no node, or a key of `probs` matches no - action label of the event. - IndexError - If a sequence `probs` does not have exactly one entry per action. + If a key of `probs` matches no action label of the event. UndefinedOperationError If any of `nodes` is a terminal node, or the game is not a tree. ValueError @@ -1983,14 +2679,25 @@ class Game: raise UndefinedOperationError( "make_event(): operation only defined for games with a tree representation" ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"make_event(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + if not isinstance(probs, typing.Mapping): + raise TypeError( + f"make_event(): probs must be a mapping, not {probs.__class__.__name__}" + ) resolved_nodes = self._resolve_nodes(nodes, "make_event") - if any(n.is_terminal for n in resolved_nodes): + if any(cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError( "make_event(): all nodes must be nonterminal" ) resolved_node = cython.cast(Node, resolved_nodes[0]) - action_labels = list((resolved_node.infoset or resolved_node.event).actions) - if any(list((n.infoset or n.event).actions) != action_labels + action_labels = list(resolved_node.actions) + if any(list(n.actions) != action_labels for n in resolved_nodes[1:]): raise ValueError( "make_event(): all nodes must have the same actions, " @@ -2006,23 +2713,30 @@ class Game: self.game.deref().MakeEvent(c_nodes, c_probs, (label or "").encode("utf-8")) def relabel_actions(self, - infoset: NodeReference, + infoset: Selector, labels: typing.Mapping[str, str], strict: bool = True) -> None: - """Simultaneously reassign the labels of actions at `infoset`. + """Simultaneously reassign the labels of actions at the information set or + event that the node identified by `infoset` belongs to. `labels` maps current action labels to their replacements. The reassignment is simultaneous, so labels can be swapped directly, e.g. ``{"a": "b", "b": "a"}``. Actions are not re-ordered: each relabelled action keeps its position and, at an event, its probability. After the operation, the labels must be nonempty and unique. + `infoset` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the information set at which to relabel actions, or - such a node's label. + infoset : Selector + A `Selector` resolving to a single node belonging to the information + set or event at which to relabel actions. labels : Mapping[str, str] A mapping from current action labels to replacement labels. Entries whose key equals their value are ignored. @@ -2033,21 +2747,25 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If `infoset` is a string matching no node; or, when `strict` - is `True`, if a key of `labels` matches no action at `infoset`. TypeError - If `labels` is not a mapping, or any key or value is not a string. + If `infoset` is not a `Selector`; or if `labels` is not a mapping, or + any key or value is not a string. + KeyError + If, when `strict` is `True`, a key of `labels` matches no action at + `infoset`. ValueError - If a key of `labels` matches more than one action at `infoset` (possible - in games read from files predating unique-label enforcement); or if any + If `infoset` does not resolve to exactly one node; if a key of + `labels` matches more than one action at `infoset` (possible in games + read from files predating unique-label enforcement); or if any replacement label is empty, is not a valid label, or would result in a duplicate label at the information set. """ + if not isinstance(infoset, Selector): + raise TypeError( + f"relabel_actions(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "relabel_actions") + Node, self._resolve_infoset_or_event(infoset, "relabel_actions") ) if not hasattr(labels, "items"): raise TypeError( @@ -2064,10 +2782,10 @@ class Game: c_labels = stdmap[string, string]() for old, new in remap.items(): c_labels[old.encode("utf-8")] = new.encode("utf-8") - self.game.deref().RelabelActions(resolved_infoset._resolve(), c_labels) + self.game.deref().RelabelActions(resolved_infoset._infoset_handle(), c_labels) def make_infoset(self, - nodes: Node | NodeReferenceSet, + nodes: Selector | GroupedSelector, player: str, label: str | None = None) -> None: """Form `nodes` into a single information set belonging to `player`. @@ -2082,11 +2800,19 @@ class Game: The structure of the tree is unchanged: no nodes are created or removed. This operation may introduce imperfect recall or absent-mindedness. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together into the one + information set). + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nodes to place in the information set. Nonempty; each node may be referenced only once. player : str @@ -2099,12 +2825,11 @@ class Game: Raises ------ - MismatchError - If any of `nodes` is from a different game. - KeyError - If any of `nodes`, or `player`, is a label matching no such object in the game. TypeError - If any of `nodes`, or `player`, is not of an accepted type. + If `nodes` is not a `Selector` or `GroupedSelector`, or `player` is not + of an accepted type. + KeyError + If `player` is a label matching no such object in the game. UndefinedOperationError If any of `nodes` is a terminal node, or if the game is not a tree. ValueError @@ -2116,10 +2841,17 @@ class Game: raise UndefinedOperationError( "make_infoset(): operation only defined for games with a tree representation" ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"make_infoset(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) resolved_nodes = self._resolve_nodes(nodes, "make_infoset") resolved_player = self._resolve_player(player, "make_infoset") for n in resolved_nodes: - if n.is_terminal: + if cython.cast(Node, n)._is_terminal(): raise UndefinedOperationError( "make_infoset(): all nodes must be decision nodes" ) @@ -2128,49 +2860,6 @@ class Game: c_nodes.push_back(cython.cast(Node, n).node) self.game.deref().MakeInfoset(c_nodes, resolved_player, (label or "").encode()) - def reveal(self, - infoset: NodeReference, - player: str) -> None: - """Reveals the move made at the information set or event `infoset` to `player`. - - Revealing the move modifies all subsequent information sets for `player` such - that any two nodes which are successors of two different actions at this - information set are placed in different information sets for `player`. - - Revelation is a one-shot operation; it is not enforced with respect to any - revisions made to the game tree subsequently. - - .. versionchanged:: 17.0.0 - Revealing the move at an absent-minded information set is not permitted. - - Parameters - ---------- - infoset : Node or str - A node belonging to the information set or event of the move to reveal - to the player, or such a node's label. - player : str - The label of the player to which to reveal the move at this information set. - - Raises - ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If no player in the game has label `player`. - UndefinedOperationError - If `infoset` is absent-minded. - """ - resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "reveal") - ) - resolved_player = self._resolve_player(player, "reveal") - if resolved_infoset.is_absent_minded: - raise UndefinedOperationError( - "reveal(): revealing the move at an absent-minded information set " - "is not well-defined" - ) - self.game.deref().Reveal(resolved_infoset._resolve(), resolved_player) - def set_players(self, players: list[str], drop: bool = False, @@ -2251,23 +2940,31 @@ class Game: def _resolve_outcome_location(self, location, funcname: str) -> tuple: """Resolve `location` for `make_outcome`/`make_outcome_null`: for a tree game, - into a list of `Node`; for a strategic game, into a list of pure-strategy - contingencies (each a mapping from player label to strategy label). + into a list of `Node` (via `_resolve_nodes`, so `location` must be a + `Selector` or `GroupedSelector`); for a strategic game, into a list of + pure-strategy contingencies (each a mapping from player label to strategy + label). Returns (is_tree, resolved). Raises ------ - MismatchError - If any node is from a different game. TypeError - If `location` is not a contingency or an iterable of contingencies + If `location` is not a `Selector` or `GroupedSelector` (tree game + only); or is not a contingency or an iterable of contingencies (strategic game only). ValueError If `location` is empty or contains a repeat, or (strategic game only) if a contingency does not specify exactly one strategy for each player. """ if self.is_tree: + if isinstance(location, GroupedSelector): + location = [n for group in self._group_nodes(location).values() for n in group] + elif not isinstance(location, Selector): + raise TypeError( + f"{funcname}(): location must be a Selector or GroupedSelector, " + f"not {location.__class__.__name__}" + ) return True, self._resolve_nodes(location, funcname) if isinstance(location, collections.abc.Mapping): entries = [location] @@ -2283,25 +2980,64 @@ class Game: self._resolve_contingency(entry, funcname, "location") for entry in entries ] + @cython.cfunc + def _resolve_payoff_mapping(self, payoffs: typing.Mapping, funcname: str) -> dict: + """Validate `payoffs` as a complete mapping from the game's players to payoff + values: every player of the game must appear exactly once. Not part of the + public API; shared by `make_outcome` and `set_outcome_payoffs`. + + Raises + ------ + TypeError + If `payoffs` is not a mapping. + KeyError + If a key of `payoffs` matches no player of the game. + ValueError + If a player appears more than once in `payoffs`, or `payoffs` does not + specify exactly one value for each player of the game. + """ + if not hasattr(payoffs, "items"): + raise TypeError( + f"{funcname}(): payoffs must be a mapping, not {payoffs.__class__.__name__}" + ) + resolved_payoffs = {} + for player, value in payoffs.items(): + self._resolve_player(player, funcname, "payoffs") + if player in resolved_payoffs: + raise ValueError(f"{funcname}(): each player may appear only once in payoffs") + resolved_payoffs[player] = value + if set(resolved_payoffs) != set(self.players): + raise ValueError( + f"{funcname}(): payoffs must be specified for each player of the game" + ) + return resolved_payoffs + def make_outcome(self, location, payoffs: typing.Mapping, - label: str) -> Outcome: + label: str) -> None: """Create an outcome with `payoffs` and `label` and attach it at `location`. - For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a - strategic game, `location` is a pure-strategy contingency — a complete mapping - from the game's players' labels to strategy labels — or an iterable of such - contingencies. + For an extensive game, `location` is a `Selector` (an `H`-built + expression, evaluated against this game and treated as a flat set of + nodes) or a `GroupedSelector` (an `H`-built `.by(...)` expression, whose + groups are pooled together, all receiving the same outcome). For a + strategic game, `location` is a pure-strategy contingency — a complete + mapping from the game's players' labels to strategy labels — or an + iterable of such contingencies. Any outcome all of whose references are among `location` is absorbed by the operation: it is removed from the game, and `label` may reuse its label. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For an extensive game, `location` is now a `Selector` or + `GroupedSelector`; a `Node`, `History`, or iterable of these is no + longer accepted directly -- build one with `H`. Parameters ---------- - location : Node, contingency, or iterable of these + location : Selector, GroupedSelector, contingency, or iterable of contingencies Where to attach the new outcome. Nonempty; each node or contingency may be referenced only once. payoffs : Mapping @@ -2311,15 +3047,11 @@ class Game: The label of the new outcome; must be nonempty and, after the operation, unique within the game. - Returns - ------- - Outcome - A reference to the newly-created outcome. - Raises ------ - MismatchError - If any node is from a different game. + TypeError + If, for an extensive game, `location` is not a `Selector` or + `GroupedSelector`. ValueError If `location` is empty or contains a repeat; if `payoffs` is not a complete mapping over exactly the game's players; if a contingency does not specify @@ -2328,25 +3060,18 @@ class Game: UndefinedOperationError If the game is in action-graph representation, where outcomes are not represented explicitly. + + See Also + -------- + get_outcome_payoffs : Get the payoffs at an outcome. + set_outcome_payoffs : Set the payoffs at an outcome. + relabel_outcomes : Change the labels of the game's outcomes. """ if self.game.deref().IsAgg(): raise UndefinedOperationError( "make_outcome(): operation not defined for games in action-graph representation" ) - if not hasattr(payoffs, "items"): - raise TypeError( - f"make_outcome(): payoffs must be a mapping, not {payoffs.__class__.__name__}" - ) - resolved_payoffs = {} - for player, value in payoffs.items(): - self._resolve_player(player, "make_outcome", "payoffs") - if player in resolved_payoffs: - raise ValueError("make_outcome(): each player may appear only once in payoffs") - resolved_payoffs[player] = value - if set(resolved_payoffs) != set(self.players): - raise ValueError( - "make_outcome(): payoffs must be specified for each player of the game" - ) + resolved_payoffs = self._resolve_payoff_mapping(payoffs, "make_outcome") c_payoffs = stdvector[c_Number]() for player in self.players: c_payoffs.push_back(_to_number(resolved_payoffs[player])) @@ -2355,9 +3080,8 @@ class Game: c_nodes = stdvector[c_GameNode]() for n in resolved: c_nodes.push_back(cython.cast(Node, n).node) - return Outcome.wrap( - self.game.deref().MakeOutcome(c_nodes, c_payoffs, label.encode("utf-8")) - ) + self.game.deref().MakeOutcome(c_nodes, c_payoffs, label.encode("utf-8")) + return c_contingencies = stdvector[stdvector[c_GameStrategy]]() for contingency in resolved: c_one = stdvector[c_GameStrategy]() @@ -2366,32 +3090,37 @@ class Game: self._resolve_strategy(player, contingency[player], "make_outcome") ) c_contingencies.push_back(c_one) - return Outcome.wrap( - self.game.deref().MakeOutcome(c_contingencies, c_payoffs, label.encode("utf-8")) - ) + self.game.deref().MakeOutcome(c_contingencies, c_payoffs, label.encode("utf-8")) def make_outcome_null(self, location) -> None: """Reset the outcome at `location` to the null outcome. - For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a - strategic game, `location` is a pure-strategy contingency — a complete mapping - from the game's players' labels to strategy labels — or an iterable of such - contingencies. + For an extensive game, `location` is a `Selector` (an `H`-built + expression, evaluated against this game and treated as a flat set of + nodes) or a `GroupedSelector` (an `H`-built `.by(...)` expression, whose + groups are pooled together). For a strategic game, `location` is a + pure-strategy contingency — a complete mapping from the game's players' + labels to strategy labels — or an iterable of such contingencies. Any outcome all of whose references are among `location` is removed from the game. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For an extensive game, `location` is now a `Selector` or + `GroupedSelector`; a `Node`, `History`, or iterable of these is no + longer accepted directly -- build one with `H`. Parameters ---------- - location : Node, contingency, or iterable of these + location : Selector, GroupedSelector, contingency, or iterable of contingencies The nodes or contingencies to reset to the null outcome. Nonempty; each node or contingency may be referenced only once. Raises ------ - MismatchError - If any node is from a different game. + TypeError + If, for an extensive game, `location` is not a `Selector` or + `GroupedSelector`. ValueError If `location` is empty or contains a repeat, or if a contingency does not specify exactly one strategy for each player. @@ -2421,6 +3150,138 @@ class Game: c_contingencies.push_back(c_one) self.game.deref().MakeOutcomeNull(c_contingencies) + def relabel_outcomes(self, labels: typing.Mapping[str, str], strict: bool = True) -> None: + """Simultaneously reassign the labels of the game's outcomes. + + `labels` maps current outcome labels to their replacements. The reassignment + is simultaneous, so labels can be swapped directly, e.g. ``{"a": "b", "b": "a"}``. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + labels : Mapping[str, str] + A mapping from current outcome labels to replacement labels. + Entries whose key equals their value are ignored. + strict : bool, default True + If `True`, every key of `labels` must be the label of an outcome of the + game, and unknown keys raise ``KeyError``. If `False`, unknown keys are + ignored. + + Raises + ------ + KeyError + When `strict` is `True`, if a key of `labels` matches no outcome of the game. + TypeError + If `labels` is not a mapping, or any key or value is not a string. + ValueError + If a key of `labels` matches more than one outcome; or if any replacement + label is empty, is not a valid label, or would result in a duplicate label. + UndefinedOperationError + If the game is in action-graph representation, where outcomes are not + represented explicitly. + + See Also + -------- + relabel_players : Simultaneously reassign the labels of the game's players. + relabel_strategies : Change the labels of a player's strategies. + """ + if self.game.deref().IsAgg(): + raise UndefinedOperationError( + "relabel_outcomes(): operation not defined for games in " + "action-graph representation" + ) + if not hasattr(labels, "items"): + raise TypeError( + f"relabel_outcomes(): labels must be a mapping, " + f"not {labels.__class__.__name__}" + ) + current = [ + o.deref().GetLabel().decode("utf-8") for o in self.game.deref().GetOutcomes() + ] + remap = _compute_relabeling( + current, labels, "relabel_outcomes", "outcome", strict, "in this game" + ) + if not remap: + return + c_labels = stdmap[string, string]() + for old, new in remap.items(): + c_labels[old.encode("utf-8")] = new.encode("utf-8") + self.game.deref().RelabelOutcomes(c_labels) + + def get_outcome_payoffs(self, label: str) -> PayoffVector: + """Returns the payoff to each player at the outcome labeled `label`. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + label : str + The label of the outcome. + + Returns + ------- + PayoffVector + + Raises + ------ + KeyError + If no outcome has label `label`. + TypeError + If `label` is not a `str`. + ValueError + If `label` is an empty string or all whitespace. + + See Also + -------- + set_outcome_payoffs : Set the payoffs at an outcome. + get_payoffs : Get the payoffs at a pure-strategy contingency. + """ + resolved_outcome: c_GameOutcome = self._resolve_outcome(label, "get_outcome_payoffs") + values = {} + for player in self.players: + resolved_player = self._resolve_player(player, "get_outcome_payoffs") + values[player] = _decode_number(resolved_outcome.deref().GetPayoff[string]( + resolved_player + )) + return PayoffVector(values) + + def set_outcome_payoffs(self, label: str, payoffs: typing.Mapping) -> None: + """Sets the payoff to each player at the outcome labeled `label`. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + label : str + The label of the outcome to modify. + payoffs : Mapping + A complete mapping from the game's players (or their labels) to payoffs. + Every player must be present; zeroes must be given explicitly. + + Raises + ------ + KeyError + If no outcome has label `label`; or if a key of `payoffs` matches no player. + TypeError + If `payoffs` is not a mapping, or `label` is not a `str`. + ValueError + If `label` is an empty string or all whitespace; or if `payoffs` is not a + complete mapping over exactly the game's players. + + See Also + -------- + get_outcome_payoffs : Get the payoffs at an outcome. + make_outcome : Create a new outcome with payoffs. + """ + resolved_outcome: c_GameOutcome = self._resolve_outcome(label, "set_outcome_payoffs") + resolved_payoffs = self._resolve_payoff_mapping(payoffs, "set_outcome_payoffs") + for player in self.players: + resolved_outcome.deref().SetPayoff( + self._resolve_player(player, "set_outcome_payoffs"), + _to_number(resolved_payoffs[player]) + ) + def relabel_strategies(self, player: str, labels: typing.Mapping[str, str], @@ -2630,22 +3491,75 @@ class Game: @dataclasses.dataclass -class NodeCoordinates: +class TreeLayoutCoordinates: + """The layout coordinates of a single node in a game tree, computed for + graphical display. + + .. versionchanged:: 17.0.0 + Renamed from `NodeCoordinates`. + """ level: int sublevel: int offset: float +class TreeLayout: + """The layout of a game's tree, computed for graphical display. + + Maps each node's History to its `TreeLayoutCoordinates`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, data: dict[tuple, TreeLayoutCoordinates]) -> None: + self._data = data + + def __repr__(self) -> str: + return f"TreeLayout({self._data!r})" + + def __len__(self) -> int: + return len(self._data) + + def __iter__(self) -> typing.Iterator[tuple]: + return iter(self._data) + + def __contains__(self, history: tuple) -> bool: + return history in self._data + + def __getitem__(self, history: tuple) -> TreeLayoutCoordinates: + return self._data[history] + + def items(self) -> typing.ItemsView[tuple, TreeLayoutCoordinates]: + return self._data.items() + + @cython.cfunc -def _layout_tree(game: Game) -> dict[Node, NodeCoordinates]: +def _layout_tree(game: Game) -> object: layout = CreateLayout(game.game) data = {} - for node in game.nodes: - data[node] = NodeCoordinates(deref(layout).GetNodeLevel(cython.cast(Node, node).node), - deref(layout).GetNodeSublevel(cython.cast(Node, node).node), - deref(layout).GetNodeOffset(cython.cast(Node, node).node)) - return data - - -def layout_tree(game: Game) -> dict[Node, NodeCoordinates]: + for node in game._all_nodes(): + data[_history_of(node)] = TreeLayoutCoordinates( + deref(layout).GetNodeLevel(cython.cast(Node, node).node), + deref(layout).GetNodeSublevel(cython.cast(Node, node).node), + deref(layout).GetNodeOffset(cython.cast(Node, node).node)) + return TreeLayout(data) + + +def layout_tree(game: Game) -> TreeLayout: + """Computes the layout of `game`'s tree for graphical display. + + .. versionchanged:: 17.0.0 + Returns a `TreeLayout` (History-keyed) instead of a + `dict[Node, NodeCoordinates]`. + + Parameters + ---------- + game : Game + The game whose tree layout to compute. + + Returns + ------- + TreeLayout + The layout of `game`'s tree. + """ return _layout_tree(game) diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi index ee2928192b..2df4013470 100644 --- a/src/pygambit/gamecollections.pxi +++ b/src/pygambit/gamecollections.pxi @@ -3,8 +3,7 @@ # Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) # # FILE: src/pygambit/gamecollections.pxi -# Cython wrappers for the collections of nodes, subgames, outcomes, and players -# belonging to a game +# Cython wrappers for the collections belonging to a game # # 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 @@ -21,123 +20,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class GameNodes: - """Represents the set of nodes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameNodes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameNodes: - obj: GameNodes = GameNodes.__new__(GameNodes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameNodes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of nodes in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().NumNodes() - - def __iter__(self) -> typing.Iterator[Node]: - """Iterate over the game nodes in the depth-first traversal order.""" - if not self.game.deref().IsTree(): - return - - for node in self.game.deref().GetNodes(): - yield Node.wrap(node) - - -@cython.cclass -class GameSubgames: - """Represents the set of subgames in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameSubgames outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameSubgames: - obj: GameSubgames = GameSubgames.__new__(GameSubgames) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameSubgames(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of subgames in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().GetSubgames().size() - - def __iter__(self) -> typing.Iterator[Subgame]: - """Iterate over the game subgames in postorder.""" - if not self.game.deref().IsTree(): - return - for subgame in self.game.deref().GetSubgames(): - yield Subgame.wrap(subgame) - - -@cython.cclass -class GameOutcomes: - """Represents the set of outcomes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameOutcomes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameOutcomes: - obj: GameOutcomes = GameOutcomes.__new__(GameOutcomes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameOutcomes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of outcomes in the game.""" - return self.game.deref().GetOutcomes().size() - - def __iter__(self) -> typing.Iterator[Outcome]: - for outcome in self.game.deref().GetOutcomes(): - yield Outcome.wrap(outcome) - - def __getitem__(self, label: str) -> Outcome: - """Returns the outcome with text label `label`. - - Parameters - ---------- - label : str - The text label of the outcome to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no outcome in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one outcome has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an outcome by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Game", "outcome", "outcomes") - - @cython.cclass class GamePlayers: """The labels of the (personal) players in a game. @@ -146,8 +28,8 @@ class GamePlayers: Iterates over player labels (``str``) rather than ``Player`` objects; indexing by label is no longer supported (a label is already in hand once iterated) -- use ``in`` to test membership. The chance player is no longer - exposed here (it never was included in iteration); the ``Infoset``/``Event`` - split on ``Node`` already distinguishes personal from chance nodes. + exposed here (it never was included in iteration); ``Node.player`` already + distinguishes personal from chance nodes. """ game = cython.declare(c_Game) diff --git a/src/pygambit/gamehelpers.pxi b/src/pygambit/gamehelpers.pxi index 10e755612c..a85c4f9ce2 100644 --- a/src/pygambit/gamehelpers.pxi +++ b/src/pygambit/gamehelpers.pxi @@ -90,22 +90,36 @@ def _compute_relabeling( return remap +def _node_infoset_kind(node: Node) -> tuple: + """Whether `node` currently belongs to a personal player's information set, and + whether it belongs to a chance event -- exactly one of the two, or neither if + `node` is currently terminal. + """ + handle: c_GameInfoset = cython.cast(Node, node)._infoset_handle() + if handle == cython.cast(c_GameInfoset, NULL): + return False, False + is_chance = handle.deref().IsChanceInfoset() + return not is_chance, is_chance + + def _resolve_infoset_or_event_kind( - this: object, other: object, this_bare: str, this_full: str, other_full: str, + resolved_node: Node, this_ok: bool, other_ok: bool, + this_bare: str, this_full: str, other_full: str, funcname: str, argname: str -) -> object: - """Shared error-raising shape for `_resolve_infoset`/`_resolve_event`: `this` is - the already-resolved `Infoset`/`Event` of the desired kind, falsy if the - anchoring node's partition element is not of that kind; `other` is the opposite - kind, consulted only to raise a more specific error when `this` does not apply. +) -> Node: + """Shared error-raising shape for `_resolve_infoset`/`_resolve_event`: `this_ok` is + whether `resolved_node`'s current partition element is of the desired kind; + `other_ok` is whether it's the opposite kind, consulted only to raise a more + specific error when `this_ok` is False. Returns `resolved_node` unchanged when + `this_ok`. """ - if not this: - if other: + if not this_ok: + if other_ok: raise ValueError(f"{funcname}(): {argname} resolves to {other_full}, not {this_full}") raise ValueError( f"{funcname}(): {argname} resolves to no {this_bare} (the node is terminal)" ) - return this + return resolved_node def _dirichlet_distribution(items: list, gen: object) -> dict: diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi new file mode 100644 index 0000000000..bc660471a3 --- /dev/null +++ b/src/pygambit/hsel.pxi @@ -0,0 +1,319 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/hsel.pxi +# First sketch of the H selector algebra: game-neutral expressions built by +# pygambit.H, evaluated only when handed to a Game. Deliberately minimal -- +# just enough operations to validate the architecture, not the full roster. +# +# 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. +# + + +class _PathStep: + """One `H.path(*steps)` operation: each step is an exact action label or + the wildcard `...`. Root-anchored if it is the first op in a Selector, + "this many more steps from here" otherwise -- the evaluator doesn't need + to distinguish the two cases, since they're the same operation applied to + whatever's already been selected (the root, for a bare seed).""" + + def __init__(self, steps: tuple) -> None: + self.steps = steps + + def __repr__(self) -> str: + return f"_PathStep(steps={self.steps!r})" + + +class _PlaysStep: + """One `.plays` operation: expand to the current terminal frontier.""" + + def __repr__(self) -> str: + return "_PlaysStep()" + + +class _AfterStep: + """One `.after(*labels)` operation: an unconstrained (possibly empty) + prefix, then exactly these trailing labels. As the first op in a + Selector, matches anywhere in the whole game, not just root's frontier -- + the natural counterpart to `.path(...)`'s root anchoring. Chained onto an + existing selection, it's a pure filter: no new nodes are considered, just + whichever already-selected ones end in this suffix.""" + + def __init__(self, labels: tuple) -> None: + self.labels = labels + + def __repr__(self) -> str: + return f"_AfterStep(labels={self.labels!r})" + + +def _matches_suffix(node: Node, labels: tuple) -> bool: + """Whether `node`'s own history ends with exactly `labels`.""" + current: Node = node + for label in reversed(labels): + parent = current._parent() + if parent is None or current._prior_action().label != label: + return False + current = parent + return True + + +class _FilterStep: + """One `.filter(callable)` operation: keep only elements where + `predicate`, given a HistoryView, returns something truthy. Chained-only + -- unlike `.after(...)`, there's no natural "whole game" domain for a + bare predicate to start from, so it's not exposed as an `H.filter(...)` + seed.""" + + def __init__(self, predicate: typing.Callable) -> None: + self.predicate = predicate + + def __repr__(self) -> str: + return f"_FilterStep(predicate={self.predicate!r})" + + +class Selector: + """A game-neutral description of a set of nodes. Carries no reference to + any game -- it's just a recipe, evaluated only when handed to a `Game` + method that accepts one, such as `append_move` or `make_outcome`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, ops: tuple = ()) -> None: + self._ops = ops + + def __repr__(self) -> str: + return f"Selector(ops={self._ops!r})" + + def _extend(self, op) -> Selector: + return Selector(self._ops + (op,)) + + def path(self, *steps: str) -> Selector: + """`N` more steps from wherever this selection currently is. Each + step is an exact action label, or `...` to match any single action. + """ + return self._extend(_PathStep(steps)) + + @property + def plays(self) -> Selector: + """The current terminal frontier of this selection -- not + necessarily one step forward, whatever is currently terminal beneath + each already-selected node.""" + return self._extend(_PlaysStep()) + + def after(self, *labels: str) -> Selector: + """Filter this selection to just the elements whose own trailing + labels are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + + def filter(self, predicate: typing.Callable) -> Selector: + """Keep only the elements of this selection where `predicate`, + called once per element with a read-only `HistoryView` of it, + returns something truthy. The general escape hatch for a filter + `.after(...)`'s label-pattern matching can't express -- e.g. + anything needing `.last_action(player)` rather than a plain + trailing-label match.""" + return self._extend(_FilterStep(predicate)) + + def by(self, key: typing.Callable) -> GroupedSelector: + """Partition this selection by `key`, called once per element with a + read-only `HistoryView` of it. Distinct return values become distinct + groups; game-neutral until evaluated, same as `Selector` itself.""" + return GroupedSelector(self, key) + + +class GroupedSelector: + """Result of `.by(callable)`. Game-neutral until evaluated -- pass to a + `Game` method that accepts a `GroupedSelector`, such as `append_move`, + which dispatches one call per group. + + `.plays`/`.after(...)` chain onto a `GroupedSelector` the same way they + chain onto a plain `Selector`, but apply per-group: each group's own + members are expanded/filtered independently, and the group's key is left + untouched -- expanding past a decision point doesn't retroactively change + what a group was keyed by. `.with_recall(player)` is the one exception: + once set, every subsequent `.plays` on this selector *also* refines each + group's key by folding in `player`'s last action at that point, so a + partition built for one decision stays a valid recall-respecting + partition when reused for a later one, without the caller needing to + re-derive or manually re-key it. Scoped to `.plays` specifically for now + (not every expand-style op) -- narrower than the full "any expand-style + step" idea from the design notes, not yet stress-tested against a shape + that would need more. + + .. versionadded:: 17.0.0 + """ + + def __init__( + self, + base: Selector, + key: typing.Callable, + post_ops: tuple = (), + recall_player: str = None, + ) -> None: + self.base = base + self.key = key + self.post_ops = post_ops + self.recall_player = recall_player + + def __repr__(self) -> str: + return ( + f"GroupedSelector(base={self.base!r}, key={self.key!r}, " + f"post_ops={self.post_ops!r}, recall_player={self.recall_player!r})" + ) + + def _extend(self, op) -> GroupedSelector: + return GroupedSelector(self.base, self.key, self.post_ops + (op,), self.recall_player) + + @property + def plays(self) -> GroupedSelector: + """The current terminal frontier of each group, independently -- + see the class docstring for how this interacts with + `.with_recall(player)`.""" + return self._extend(_PlaysStep()) + + def after(self, *labels: str) -> GroupedSelector: + """Filter each group to just the members whose own trailing labels + are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + + def with_recall(self, player: str) -> GroupedSelector: + """From here on, every `.plays` on this selector also refines each + group's key by folding in `player`'s last action at that point -- + see the class docstring.""" + return GroupedSelector(self.base, self.key, self.post_ops, player) + + +def _history_of(node: Node) -> tuple: + """The plain-tuple History for `node` -- walks back to the root via the + private `Node._parent`/`._prior_action` navigation.""" + labels: list = [] + current: Node = node + while current._parent() is not None: + labels.append(current._prior_action().label) + current = current._parent() + labels.reverse() + return tuple(labels) + + +def _canonical_history(node: Node) -> tuple: + """The History of the canonical member of `node`'s current information set or + event: the first member encountered in the pre-order depth-first traversal of the + game tree (``GetMember(1)``), matching the order `Node.members` itself uses. Used + as the stable key identifying an information set or event, in place of any one + particular member node. + + Raises + ------ + AttributeError + If `node` currently belongs to no information set or event (a terminal node). + """ + resolved: c_GameInfoset = node._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL): + raise AttributeError("node currently belongs to no information set or event") + return _history_of(Node.wrap(resolved.deref().GetMember(1))) + + +class HistoryView: + """The object a `.filter(callable)`/`.by(callable)` predicate, or + `Game.behavior_support_profile`'s `actions` callback, actually receives. + Supports plain sequence indexing/slicing like a `History` tuple, plus + limited game-aware navigation (`.last_action(player)`) -- but never + exposes the `Node`/game it's privately backed by. Not constructible + directly. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a HistoryView directly.") + + @staticmethod + def _wrap(node: Node, history: tuple) -> HistoryView: + obj: HistoryView = HistoryView.__new__(HistoryView) + obj._node = node + obj._history = history + return obj + + def __repr__(self) -> str: + return f"HistoryView({self._history!r})" + + def __len__(self) -> int: + return len(self._history) + + def __getitem__(self, index: typing.Any) -> typing.Any: + return self._history[index] + + def last_action(self, player: str) -> str | None: + """The label of the last action `player` took on the path to this + history, wherever it fell -- `None` if `player` hasn't acted yet.""" + return _last_action(self._node, player) + + @property + def members(self) -> list[tuple]: + """The Histories of the nodes which are members of the information set or + event to which this history currently belongs -- whichever applies. + + Raises + ------ + AttributeError + If this history currently belongs to no information set or event (a + terminal node). + """ + return [_history_of(member) for member in self._node.members] + + +def _last_action(node: Node, player: str) -> str | None: + """The label of the last action `player` took on the path to `node`, + wherever it fell -- `None` if `player` hasn't acted yet. Shared between + `HistoryView.last_action` and `.with_recall(player)`'s evaluation.""" + current: Node = node + while current._parent() is not None: + if current._parent().player == player: + return current._prior_action().label + current = current._parent() + return None + + +class H: + """Namespace of seed constructors for the node-selector algebra. Not + meant to be instantiated -- use as `H.path(...)`, conventionally imported + as `import pygambit.H as H`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("H is a namespace of selector constructors, not instantiable.") + + @staticmethod + def path(*steps: str) -> Selector: + """A root-anchored selection. Each step is an exact action label, or + `...` to match any single action. `H.path()` with no steps selects + the root itself. + """ + return Selector().path(*steps) + + @staticmethod + def after(*labels: str) -> Selector: + """Anywhere in the whole game whose own trailing labels are exactly + `labels`, whatever came before them -- the suffix-anchored + counterpart to the root-anchored `.path(...)`. + """ + return Selector().after(*labels) + + plays: Selector = Selector((_PlaysStep(),)) + """All currently-terminal nodes in the whole game.""" diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi deleted file mode 100644 index 14f6824c24..0000000000 --- a/src/pygambit/infoset.pxi +++ /dev/null @@ -1,220 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/pygambit/infoset.pxi -# Cython wrapper for information sets -# -# 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. -# - -@cython.cclass -class _InfosetOrEvent: - """Shared implementation for `Infoset` and `Event`: a lazy, node-anchored view over - an information set, filtered to whichever of the two subclasses' concept currently - applies at the anchoring node (see each subclass's `_try_resolve`). - - Not exported; only `Infoset` and `Event` are part of the public API. - """ - node = cython.declare(c_GameNode) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError(f"Cannot create an {type(self).__name__} outside a Game.") - - @cython.cfunc - def _try_resolve(self) -> c_GameInfoset: - """Returns the resolved handle, filtered to this subclass's applicable case; - null if that case does not currently apply at the anchoring node (including, - but not limited to, a terminal node).""" - raise NotImplementedError - - @cython.cfunc - def _resolve(self) -> c_GameInfoset: - """Returns the resolved handle, raising if this subclass's case does not - currently apply at the anchoring node.""" - resolved: c_GameInfoset = self._try_resolve() - if resolved == cython.cast(c_GameInfoset, NULL): - raise AttributeError( - f"node's {type(self).__name__.lower()} is currently None" - ) - return resolved - - def __repr__(self) -> str: - if self._try_resolve() == cython.cast(c_GameInfoset, NULL): - return "None" - name = type(self).__name__ - if self.label: - return f"{name}(player={self.player}, label='{self.label}')" - else: - return f"{name}(player={self.player}, number={self.number})" - - def __eq__(self, other: typing.Any): - if type(other) is not type(self): - return NotImplemented - mine: c_GameInfoset = self._try_resolve() - theirs: c_GameInfoset = cython.cast(_InfosetOrEvent, other)._try_resolve() - if mine == cython.cast(c_GameInfoset, NULL) or theirs == cython.cast(c_GameInfoset, NULL): - return ( - mine == cython.cast(c_GameInfoset, NULL) and - theirs == cython.cast(c_GameInfoset, NULL) - ) - return mine == theirs - - def __bool__(self) -> bool: - return self._try_resolve() != cython.cast(c_GameInfoset, NULL) - - def __hash__(self) -> int: - resolved: c_GameInfoset = self._try_resolve() - if resolved == cython.cast(c_GameInfoset, NULL): - return 0 - return cython.cast(cython.long, resolved.deref()) - - def precedes(self, node: Node) -> bool: - """Return whether this information set precedes `node` in the game tree.""" - return self._resolve().deref().Precedes(cython.cast(Node, node).node) - - @property - def game(self) -> Game: - """The ``Game`` to which the information set belongs.""" - return Game.wrap(self._resolve().deref().GetGame()) - - @property - def label(self) -> str: - """Get or set the text label of the information set. - - .. versionchanged:: 17.0.0 - A label may now be any well-formed UTF-8 text, not just ASCII; it must still - contain no control characters, and must not begin/end with whitespace or have - two consecutive whitespace characters. "Whitespace" means any Unicode space - separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. - """ - return self._resolve().deref().GetLabel().decode("utf-8") - - @label.setter - def label(self, value: str) -> None: - self._resolve().deref().SetLabel(value.encode("utf-8")) - - @property - def number(self) -> int: - """Returns the number of the information set for its player. - Information sets are numbered starting with 0. - """ - return self._resolve().deref().GetNumber() - 1 - - @property - def is_absent_minded(self) -> bool: - """ - Whether the information set is absent-minded. - - An information set is absent-minded if there exists a path of play - in the game tree that intersects the information set more than once. - - .. versionadded:: 16.5.0 - """ - resolved: c_GameInfoset = self._resolve() - return resolved.deref().GetGame().deref().IsAbsentMinded(resolved) - - @property - def actions(self) -> list[str]: - """The labels of the actions available at the information set, in order. - - .. versionchanged:: 17.0.0 - Returns bare labels rather than ``Action`` objects, following its removal. - """ - resolved: c_GameInfoset = self._resolve() - return [a.deref().GetLabel().decode("utf-8") for a in resolved.deref().GetActions()] - - @property - def members(self) -> list[Node]: - """The nodes which are members of the information set. - - The order of information set members is the order in which they are - encountered in the pre-order depth first traversal of the game tree. - - .. versionchanged:: 17.0.0 - Returns a plain ``list`` rather than a lazily-resolved collection; a - member is no longer accessible by label, following the removal of - ``Action``/``Strategy`` label-indexed collections elsewhere in the API. - """ - resolved: c_GameInfoset = self._resolve() - return [Node.wrap(member) for member in resolved.deref().GetMembers()] - - @property - def player(self) -> str: - """The label of the player who has the move at this information set.""" - return self._resolve().deref().GetPlayer().deref().GetLabel().decode("utf-8") - - -@cython.cclass -class Infoset(_InfosetOrEvent): - """An information set belonging to a personal player in a ``Game``: the point at - which that player chooses an action, and so the object of potential optimisation. - The corresponding concept for the chance player is an ``Event``. - - A lazy, node-anchored view: holds a member node and resolves the information set - on each access, so the value reflects the current state of the game even after - the game is mutated. For a node currently belonging to no personal player's - information set (a terminal node, or a chance event -- see ``Node.event``), the - view is falsy and equals ``None``. - - .. versionchanged:: 17.0.0 - Now a node-anchored view (see ``Node.infoset``) rather than an object with - identity of its own; equality/hashing are still based on the information set - currently resolved, not on the anchoring node. No longer used for the chance - player's events; see ``Event``. - """ - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> Infoset: - obj: Infoset = Infoset.__new__(Infoset) - obj.node = node - return obj - - @cython.cfunc - def _try_resolve(self) -> c_GameInfoset: - resolved: c_GameInfoset = self.node.deref().GetInfoset() - if resolved != cython.cast(c_GameInfoset, NULL) and resolved.deref().IsChanceInfoset(): - return cython.cast(c_GameInfoset, NULL) - return resolved - - -@cython.cclass -class Event(_InfosetOrEvent): - """An event belonging to the chance player in a ``Game``: a point of exogenous - randomness, with a probability distribution over its actions that is specified - rather than chosen. The corresponding concept for a personal player is an - ``Infoset``. - - A lazy, node-anchored view: holds a member node and resolves the event on each - access, so the value reflects the current state of the game even after the game - is mutated. For a node not currently belonging to a chance event (a terminal - node, or a personal player's information set -- see ``Node.infoset``), the view - is falsy and equals ``None``. - - .. versionadded:: 17.0.0 - """ - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> Event: - obj: Event = Event.__new__(Event) - obj.node = node - return obj - - @cython.cfunc - def _try_resolve(self) -> c_GameInfoset: - resolved: c_GameInfoset = self.node.deref().GetInfoset() - if resolved != cython.cast(c_GameInfoset, NULL) and not resolved.deref().IsChanceInfoset(): - return cython.cast(c_GameInfoset, NULL) - return resolved diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index d8e8fa98a3..7689cfdaf2 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -21,20 +21,18 @@ # Branch = collections.namedtuple("Branch", ["node", "label"]) -Branch.__doc__ = """The action labeled `label`, taken at `node`. - -Returned by `Node.prior_action` and `Node.own_prior_action`; `node` is the node at -which the action was taken (not the node it leads to), so ``branch.node.actions`` +Branch.__doc__ = """The action labeled `label`, taken at `node`. Not part of the +public API; internal return type of the private `Node._prior_action`/ +`._own_prior_action`, pending a `Game`/`History`-based design. `node` is the node +at which the action was taken (not the node it leads to), so ``branch.node.actions`` and, for a chance event, ``branch.node.action_probs[branch.label]`` are always well-defined. - -.. versionadded:: 17.0.0 """ @cython.cfunc -def _decode_prob(py_string: string) -> object: - """Internal: decode a probability formatted by the C++ core as ``Decimal`` or +def _decode_number(py_string: string) -> object: + """Internal: decode a number formatted by the C++ core as ``Decimal`` or ``Rational``, matching whichever representation was used to specify it.""" if "." in py_string.decode("ascii"): return decimal.Decimal(py_string.decode("ascii")) @@ -110,68 +108,6 @@ class NodeChildren: raise TypeError(f"Index must be a str label, not {action.__class__.__name__}") -@cython.cclass -class NodeOutcome: - """The outcome attached to a node. - - A lazy, node-anchored view: holds the node and resolves its outcome on each access, - so the value reflects the current state of the game even after the game is mutated. - - .. versionadded:: 16.7.0 - - .. versionchanged:: 17.0.0 - A node with no outcome attached resolves to the game's null outcome: the view is - falsy, its ``label`` is ``None``, its payoffs read as zero, and it compares unequal - to every outcome — including another null and itself — and to ``None``. - """ - node = cython.declare(c_GameNode) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a NodeOutcome outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> NodeOutcome: - obj: NodeOutcome = NodeOutcome.__new__(NodeOutcome) - obj.node = node - return obj - - @cython.cfunc - def _resolve(self) -> Outcome: - return Outcome.wrap(self.node.deref().GetOutcome()) - - def __getattr__(self, name): - if name.startswith("_"): - raise AttributeError(f"'NodeOutcome' object has no attribute '{name}'") - return getattr(self._resolve(), name) - - def __getitem__(self, player): - return self._resolve()[player] - - def __setitem__(self, player, value): - self._resolve()[player] = value - - @property - def label(self): - return self._resolve().label - - @label.setter - def label(self, value): - self._resolve().label = value - - def __repr__(self) -> str: - return repr(self._resolve()) - - def __eq__(self, other: typing.Any) -> bool: - return self._resolve() == other - - def __bool__(self) -> bool: - return not self.node.deref().GetOutcome().deref().IsNull() - - def __hash__(self) -> int: - return hash(self._resolve()) - - @cython.cclass class Node: """A node in a ``Game``.""" @@ -188,16 +124,16 @@ class Node: return obj def __repr__(self) -> str: - if self.label: - return f"Node(game={self.game}, label='{self.label}')" + if self._label: + return f"Node(game={self._game()}, label='{self._label}')" path = [] node = self - while node.parent: + while node._parent(): path.append( cython.cast(Node, node).node.deref().GetPriorAction().deref().GetNumber() - 1 ) - node = node.parent - return f"Node(game={self.game}, path={path})" + node = node._parent() + return f"Node(game={self._game()}, path={path})" def __eq__(self, other: typing.Any) -> bool: return ( @@ -208,78 +144,55 @@ class Node: def __hash__(self) -> long: return cython.cast(long, self.node.deref()) - def is_successor_of(self, node: Node) -> bool: - """Returns whether this node is a successor of `node`.""" + def _is_successor_of(self, node: Node) -> bool: + """Whether this node is a successor of `node`. Not part of the public + API, pending a `Game`/`History`-based design. + """ return self.node.deref().IsSuccessorOf((node).node) @property - def label(self) -> str: - """The text label associated with the node. - - .. versionchanged:: 17.0.0 - A label may now be any well-formed UTF-8 text, not just ASCII; it must still - contain no control characters, and must not begin/end with whitespace or have - two consecutive whitespace characters. "Whitespace" means any Unicode space - separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. + def _label(self) -> str: + """The text label associated with the node. Not part of the public API, + pending a `Game`/`Selector`-based design. """ return self.node.deref().GetLabel().decode("utf-8") - @label.setter - def label(self, value: str) -> None: + @_label.setter + 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. + def _number(self) -> int: + """The number of the node in its game, numbered starting with 0. Not part + of the public API; a node's position is otherwise only exposed via History. """ return self.node.deref().GetNumber() - 1 - @property - def children(self) -> NodeChildren: - """The set of children of this node.""" + def _children(self) -> NodeChildren: + """The set of children of this node. Not part of the public API; the public + equivalent is a `Selector`'s `.path(..., ...)` wildcard step, e.g. + `game.get_histories(H.path(*history, ...))`. + """ return NodeChildren.wrap(self.node) - @property - def game(self) -> Game: - """Gets the ``Game`` to which the node belongs.""" - return Game.wrap(self.node.deref().GetGame()) - - @property - def infoset(self) -> Infoset: - """The personal player's information set to which this node currently belongs. - - Returns a lazy, node-anchored view resolved on each access, so the value reflects - the current state of the game even if the game is mutated after this property is read. - For a node that does not currently belong to a personal player's information set - (a terminal node, or a chance event -- see `event`), the view is falsy and equals - ``None``. - - .. versionchanged:: 16.7.0 - .. versionchanged:: 17.0.0 - No longer resolves to the chance player's events; see `event`. + def _game(self) -> Game: + """The `Game` to which the node belongs. Not part of the public API; a + `Node` is otherwise only obtained already scoped to a particular `Game`. """ - return Infoset.wrap(self.node) - - @property - def event(self) -> Event: - """The chance event to which this node currently belongs. - - Returns a lazy, node-anchored view resolved on each access, so the value reflects - the current state of the game even if the game is mutated after this property is read. - For a node that is not currently a chance event (a terminal node, or a personal - player's information set -- see `infoset`), the view is falsy and equals ``None``. + return Game.wrap(self.node.deref().GetGame()) - .. versionadded:: 17.0.0 + @cython.cfunc + def _infoset_handle(self) -> c_GameInfoset: + """The node's current information set or event, as a raw handle -- null if + the node is currently terminal. Not part of the public API; the information + set/event a node belongs to is otherwise only exposed piecemeal, via + `members`/`actions`/`action_probs` and the `Game`/`Selector`-based resolvers. """ - return Event.wrap(self.node) + return self.node.deref().GetInfoset() @property def members(self) -> list[Node]: """The nodes which are members of the information set or event to which - this node currently belongs -- whichever applies. Equivalent to - ``self.infoset.members`` or ``self.event.members``, whichever is not falsy; - unlike those, this is well-defined regardless of which currently applies. + this node currently belongs -- whichever applies. .. versionadded:: 17.0.0 @@ -289,10 +202,10 @@ class Node: If this node currently belongs to no information set or event (a terminal node). """ - infoset: Infoset = self.infoset - if infoset: - return infoset.members - return self.event.members + resolved: c_GameInfoset = self._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL): + raise AttributeError("node currently belongs to no information set or event") + return [Node.wrap(member) for member in resolved.deref().GetMembers()] @property def actions(self) -> list[str]: @@ -307,10 +220,10 @@ class Node: If this node currently belongs to no information set or event (a terminal node). """ - infoset: Infoset = self.infoset - if infoset: - return infoset.actions - return self.event.actions + resolved: c_GameInfoset = self._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL): + raise AttributeError("node currently belongs to no information set or event") + return [a.deref().GetLabel().decode("utf-8") for a in resolved.deref().GetActions()] @property def action_probs(self) -> dict[str, decimal.Decimal | Rational]: @@ -324,15 +237,14 @@ class Node: UndefinedOperationError If the node does not currently belong to a chance event. """ - event: Event = self.event - if not event: + resolved: c_GameInfoset = self._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL) or not resolved.deref().IsChanceInfoset(): raise UndefinedOperationError( "action probabilities are only defined at events" ) - resolved: c_GameInfoset = event._resolve() result: dict = {} for a in resolved.deref().GetActions(): - result[a.deref().GetLabel().decode("utf-8")] = _decode_prob( + result[a.deref().GetLabel().decode("utf-8")] = _decode_number( cython.cast(string, resolved.deref().GetActionProb(a)) ) return result @@ -354,48 +266,29 @@ class Node: return None return player.deref().GetLabel().decode("utf-8") - @property - def parent(self) -> Node | None: - """The parent of this node. - - If this is the root node, None is returned. + def _parent(self) -> Node | None: + """The parent of this node, or None if this is the root node. Not part + of the public API, pending a `Game`/`History`-based design. """ if self.node.deref().GetParent() != cython.cast(c_GameNode, NULL): return Node.wrap(self.node.deref().GetParent()) return None - @property - def prior_action(self) -> Branch | None: + def _prior_action(self) -> Branch | None: """The branch -- the parent node and the label of the action taken from it -- - which leads to this node. - - If this is the root node, None is returned. - - .. versionchanged:: 17.0.0 - Returns a `Branch` (the parent node and the action's label) rather than - an `Action` object, following its removal. + which leads to this node, or None if this is the root node. Not part of the + public API, pending a `Game`/`History`-based design. """ prior: c_GameAction = self.node.deref().GetPriorAction() if prior != cython.cast(c_GameAction, NULL): - return Branch(self.parent, prior.deref().GetLabel().decode("utf-8")) + return Branch(self._parent(), prior.deref().GetLabel().decode("utf-8")) return None - @property - def own_prior_action(self) -> Branch | None: + def _own_prior_action(self) -> Branch | None: """The last branch -- the node and the label of the action taken there -- at - which the node's owner acted before reaching this node. - - Returns - ------- - Branch or None - The node at which the node's owner last acted, paired with the label of - the action taken there, or None if the player has not moved previously - on the path to this node. - - .. versionadded:: 16.5.0 - .. versionchanged:: 17.0.0 - Returns a `Branch` (the node and the action's label) rather than an - `Action` object, following its removal. + which the node's owner acted before reaching this node, or None if the player + has not moved previously on the path to this node. Not part of the public + API, pending a `Game`/`History`-based design. """ prior: c_GameAction = self.node.deref().GetOwnPriorAction() if not (prior != cython.cast(c_GameAction, NULL)): @@ -406,143 +299,24 @@ class Node: cur = cur.deref().GetParent() return Branch(Node.wrap(cur.deref().GetParent()), label) - @property - def prior_sibling(self) -> Node | None: - """The node which is immediately before this one in its parent's children. - - If this is the root node or the first child of its parent, - None is returned. - """ - if self.node.deref().GetPriorSibling() != cython.cast(c_GameNode, NULL): - return Node.wrap(self.node.deref().GetPriorSibling()) - return None - - @property - def next_sibling(self) -> Node | None: - """The node which is immediately after this one in its parent's children. - - If this is the root node or the last child of its parent, - None is returned. + @cython.cfunc + def _is_terminal(self) -> cython.bint: + """Whether this is a terminal node of the game. Not part of the public + API; a node is terminal exactly when `Game.get_actions` is empty for it. """ - if self.node.deref().GetNextSibling() != cython.cast(c_GameNode, NULL): - return Node.wrap(self.node.deref().GetNextSibling()) - return None - - @property - def is_terminal(self) -> bool: - """Returns whether this is a terminal node of the game.""" return self.node.deref().IsTerminal() - @property - def is_subgame_root(self) -> bool: - """Returns whether the node is the root of a proper subgame. - - .. versionchanged:: 16.1.0 - Changed to being a property instead of a member function. - """ - return self.node.deref().IsSubgameRoot() - - @property - def is_strategy_reachable(self) -> bool: - """Returns whether this node is reachable by any pure strategy profile. - - A node is considered reachable if there exists at least one pure - strategy profile where the resulting path of play passes - through that node. - - In games with absent-mindedness, some nodes may be unreachable because - any path to them requires conflicting choices at the same information set. + @cython.cfunc + def _is_strategy_reachable(self) -> cython.bint: + """Whether this node is reachable by any pure strategy profile. Not part + of the public API; the public equivalent is `Game.get_strategy_unreachable`. """ return self.node.deref().IsStrategyReachable() - @property - def outcome(self) -> NodeOutcome: - """The outcome currently attached to this node. - - Returns a lazy, node-anchored view resolved on each access, so the value reflects - the current state of the game even if the game is mutated after this property is read. - When no outcome is attached, the view resolves to the game's null outcome: - its ``label`` is ``None``, its payoffs read as zero, and it compares unequal - to every outcome, including another null. - - .. versionchanged:: 16.7.0 - Now returns a lazily-evaluated, node-anchored view rather than capturing the - outcome at the time of access. - - .. versionchanged:: 17.0.0 - Resolves to the null outcome rather than ``None`` when no outcome is attached; - two null outcomes compare unequal. - """ - return NodeOutcome.wrap(self.node) - - @property - def plays(self) -> list[Node]: - """Returns a list of all terminal `Node` objects consistent with it. - """ - return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] - - -@cython.cclass -class Subgame: - """A subgame in a ``Game``. - - .. versionadded:: 16.7.0 - """ - subgame = cython.declare(c_GameSubgame) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a Subgame outside a Game.") - - @staticmethod @cython.cfunc - def wrap(subgame: c_GameSubgame) -> Subgame: - obj: Subgame = Subgame.__new__(Subgame) - obj.subgame = subgame - return obj - - def __repr__(self) -> str: - return f"Subgame(root={self.root})" - - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Subgame) and - self.subgame.deref() == cython.cast(Subgame, other).subgame.deref() - ) - - def __hash__(self) -> int: - return cython.cast(cython.long, self.subgame.deref()) - - @property - def game(self) -> Game: - """Gets the ``Game`` to which the subgame belongs. - - .. versionadded:: 16.7.0 - """ - return Game.wrap(self.subgame.deref().GetGame()) - - @property - def root(self) -> Node: - """Returns the root node of the subgame. - - .. versionadded:: 16.7.0 + def _plays(self) -> list: + """The terminal nodes consistent with this node. Not part of the public + API; the public equivalent is a `Selector`'s `.plays` step, e.g. + `game.get_histories(H.path(...).plays)`. """ - return Node.wrap(self.subgame.deref().GetRoot()) - - @property - def parent(self) -> typing.Optional[Subgame]: - """Returns the parent subgame, or None if this is the root subgame. - - .. versionadded:: 16.7.0 - """ - parent: c_GameSubgame = self.subgame.deref().GetParent() - if parent != cython.cast(c_GameSubgame, NULL): - return Subgame.wrap(parent) - return None - - @property - def children(self) -> list[Subgame]: - """Returns the immediate child subgames of this subgame. - - .. versionadded:: 16.7.0 - """ - return [Subgame.wrap(child) for child in self.subgame.deref().GetChildren()] + return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi deleted file mode 100644 index 91703ed817..0000000000 --- a/src/pygambit/outcome.pxi +++ /dev/null @@ -1,160 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/pygambit/outcome.pxi -# Cython wrapper for outcomes -# -# 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. -# -import cython - -import typing - - -@cython.cclass -class Outcome: - """An outcome in a ``Game``.""" - outcome = cython.declare(c_GameOutcome) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create an Outcome outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(outcome: c_GameOutcome) -> Outcome: - obj: Outcome = Outcome.__new__(Outcome) - obj.outcome = outcome - return obj - - def __repr__(self) -> str: - if self.outcome.deref().IsNull(): - return f"Outcome(game={self.game}, label=None)" - if self.label: - return f"Outcome(game={self.game}, label='{self.label}')" - else: - return f"Outcome(game={self.game}, number={self.number})" - - def __eq__(self, other: typing.Any): - if not isinstance(other, Outcome): - return NotImplemented - if (self.outcome.deref().IsNull() - or cython.cast(Outcome, other).outcome.deref().IsNull()): - # Null outcomes are not equal to anything, including themselves (cf. nan). - return False - return self.outcome.deref() == cython.cast(Outcome, other).outcome.deref() - - def __hash__(self) -> int: - return cython.cast(cython.long, self.outcome.deref()) - - def __bool__(self) -> bool: - """``True`` for a real outcome; the null outcome is falsy.""" - return not self.outcome.deref().IsNull() - - @property - def game(self) -> Game: - """Returns the game with which this outcome is associated.""" - return Game.wrap(self.outcome.deref().GetGame()) - - @property - def label(self) -> str | None: - """The text label associated with this outcome. - - The null outcome's label is ``None``; testing ``outcome.label is None`` is the - idiomatic nullity check. - - .. versionchanged:: 16.7.0 - An outcome label must be nonempty and unique within the game; an empty or duplicate - label now raises ``ValueError``. - - .. versionchanged:: 17.0.0 - A label may now be any well-formed UTF-8 text, not just ASCII; it must still - contain no control characters, and must not begin/end with whitespace or have - two consecutive whitespace characters. "Whitespace" means any Unicode space - separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. - The null outcome resolves with label ``None``. - """ - if self.outcome.deref().IsNull(): - return None - return self.outcome.deref().GetLabel().decode("utf-8") - - @label.setter - def label(self, value: str) -> None: - self.outcome.deref().SetLabel(value.encode("utf-8")) - - @property - def number(self) -> int | None: - """Returns the number of the outcome in the game. - Outcomes are numbered starting with 0. - - The null outcome is not a member of the game's outcomes, so it has no number. - - .. versionchanged:: 17.0.0 - The null outcome resolves here with number ``None``. - """ - if self.outcome.deref().IsNull(): - return None - return self.outcome.deref().GetNumber() - 1 - - def __getitem__( - self, player: str - ) -> decimal.Decimal | Rational: - """The payoff to `player` at the outcome. - - The null outcome reports a zero payoff to every player of its game. - - Raises - ------ - KeyError - If no player of the outcome's game has label `player`. - """ - game: Game = self.game - resolved_player: c_GamePlayer = game._resolve_player(player, "Outcome.__getitem__") - payoff = ( - self.outcome.deref().GetPayoff[string](resolved_player).decode("ascii") - ) - if "." in payoff: - return decimal.Decimal(payoff) - else: - return Rational(payoff) - - def __setitem__(self, player: str, value: typing.Any) -> None: - """Set the payoff to `player` at the outcome. - - Parameters - ---------- - player : str - The label of the player for which to set the payoff. - value : Any - The value of the payoff. This can be any numeric type, or any object that - has a string representation which can be interpreted as a number. - - Raises - ------ - KeyError - If no player of the outcome's game has label `player`. - ValueError - If `value` cannot be interpreted as a number. - UndefinedOperationError - If this is the null outcome; payoffs cannot be set on it. - """ - if self.outcome.deref().IsNull(): - raise UndefinedOperationError( - "Payoffs cannot be set on the null outcome; " - "use Game.make_outcome to create and attach an outcome" - ) - game: Game = self.game - resolved_player: c_GamePlayer = game._resolve_player(player, "Outcome.__setitem__") - self.outcome.deref().SetPayoff(resolved_player, _to_number(value)) diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 22de0655e0..72a36d944e 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -257,16 +257,19 @@ def _estimate_behavior_empirical( data: libgbt.MixedBehaviorProfile, ) -> LogitQREMixedBehaviorFitResult: flattened_data = [ - data[node][a] + data[libgbt.H.path(*history)][a] for p in data.game.players - for node in data.game.get_infosets(p) - for a in node.infoset.actions + for history in data.game.get_infosets(p) + for a in data.game.get_actions(libgbt.H.path(*history)) ] normalized = data.normalize() regrets = [ - [-normalized.action_regrets[node][a] for a in node.infoset.actions] + [ + -normalized.action_regrets[libgbt.H.path(*history)][a] + for a in data.game.get_actions(libgbt.H.path(*history)) + ] for player in data.game.players - for node in data.game.get_infosets(player) + for history in data.game.get_infosets(player) ] res = scipy.optimize.minimize( lambda x: -_empirical_log_like(x[0], regrets, flattened_data), @@ -276,9 +279,10 @@ def _estimate_behavior_empirical( profile = data.game.mixed_behavior_profile() log_probs = iter(_empirical_log_logit_probs(res.x[0], regrets)) for player in data.game.players: - for node in data.game.get_infosets(player): - profile[node] = { - a: math.exp(next(log_probs)) for a in node.infoset.actions + for history in data.game.get_infosets(player): + selector = libgbt.H.path(*history) + profile[selector] = { + a: math.exp(next(log_probs)) for a in data.game.get_actions(selector) } return LogitQREMixedBehaviorFitResult( data, "empirical", res.x[0], profile, -res.fun diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 6e506fc4e4..c2641cf981 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -24,10 +24,11 @@ class StrategyBehavior: """A read-only, map-like view of the actions prescribed by a reduced strategy. - The keys of the mapping are the information sets of the strategy's player at - which the strategy prescribes an action; an unreachable information set is not a key. - The corresponding values are the labels of the prescribed actions. - Iteration yields the keys in the player's information set order. + The keys of the mapping are Histories identifying the information sets of the + strategy's player at which the strategy prescribes an action; an unreachable + information set is not a key. The corresponding values are the labels of the + prescribed actions. Iteration yields the keys in the player's information set + order. .. versionadded:: 17.0.0 """ @@ -63,86 +64,102 @@ class StrategyBehavior: """The label of the strategy of which this is the behavior.""" return self._strategy_label - def _action_at(self, infoset: Infoset) -> str | None: - """The label of the action prescribed by the strategy at `infoset`, or None - if unreachable.""" + def _action_at(self, node: Node) -> str | None: + """The label of the action prescribed by the strategy at node's information + set, or None if unreachable.""" handle = self._game._resolve_strategy( self._player_label, self._strategy_label, "StrategyBehavior" ) - action: c_GameAction = handle.deref().GetAction(cython.cast(Infoset, infoset)._resolve()) + action: c_GameAction = handle.deref().GetAction(cython.cast(Node, node)._infoset_handle()) if not action: return None return action.deref().GetLabel().decode("utf-8") - def _resolve_key(self, key: Infoset | str) -> Infoset: - """Resolve `key` to an information set at which the player has the move.""" - infoset: Infoset - if isinstance(key, Infoset): - infoset = key - if infoset.game != self._game: - raise MismatchError("StrategyBehavior: key must be part of the same game") - else: - infoset = self._game._resolve_infoset(key, "StrategyBehavior", "key") - if infoset.player != self._player_label: + def _resolve_key(self, key: Selector) -> Node: + """Resolve `key` to a node at the information set at which the player has + the move.""" + if not isinstance(key, Selector): + raise TypeError( + f"StrategyBehavior key must be Selector, not {key.__class__.__name__}" + ) + resolved_node = self._game._resolve_infoset(key, "StrategyBehavior", "key") + if resolved_node.player != self._player_label: raise ValueError( - f"Player '{self._player_label}' does not have the move at {infoset}." + f"Player '{self._player_label}' does not have the move at {resolved_node}." ) - return infoset + return resolved_node + + def __getitem__(self, key: Selector) -> str: + """Return the label of the action prescribed at the information set `key` + resolves to. - def __getitem__(self, key: Infoset | str) -> str: - """Return the label of the action prescribed at the information set - referenced by `key`. + Parameters + ---------- + key : Selector + An `H`-built expression resolving to a single node belonging to the + information set to look up. Raises ------ + TypeError + If `key` is not a ``Selector``. KeyError - If the strategy prescribes no action at the information set, - or if `key` is a string and no information set has that label. + If the strategy prescribes no action at the information set. ValueError - If the information set belongs to a different player. + If the information set belongs to a different player, or `key` + resolves to a terminal node or a chance event. """ - infoset = self._resolve_key(key) - action = self._action_at(infoset) + node = self._resolve_key(key) + action = self._action_at(node) if action is None: raise KeyError( - f"Strategy '{self._strategy_label}' prescribes no action at {infoset}." + f"Strategy '{self._strategy_label}' prescribes no action at {node}." ) return action - def get(self, key: Infoset | str, default: typing.Any = None) -> str | None: + def get(self, key: Selector, default: typing.Any = None) -> str | None: """Return the label of the action prescribed at `key`, or `default` if none is prescribed.""" - infoset = self._resolve_key(key) - action = self._action_at(infoset) + node = self._resolve_key(key) + action = self._action_at(node) return default if action is None else action def __contains__(self, key: typing.Any) -> bool: try: - infoset = self._resolve_key(key) + node = self._resolve_key(key) except (KeyError, ValueError, TypeError): return False - return self._action_at(infoset) is not None + return self._action_at(node) is not None - def __iter__(self) -> typing.Iterator[Infoset]: - for node in self._game.get_infosets(self._player_label): - infoset = node.infoset - if self._action_at(infoset) is not None: - yield infoset + def _reachable_nodes(self) -> typing.Iterator[Node]: + """The representative nodes of the player's information sets at which the + strategy prescribes an action, in the player's information set order.""" + for node in self._game._get_infosets(self._player_label): + if self._action_at(node) is not None: + yield node + + def __iter__(self) -> typing.Iterator[tuple]: + for node in self._reachable_nodes(): + yield _canonical_history(node) def __len__(self) -> int: return sum(1 for _ in self) - def keys(self) -> list[Infoset]: - """The information sets at which the strategy prescribes an action.""" + def keys(self) -> list[tuple]: + """The Histories identifying the information sets at which the strategy + prescribes an action.""" return list(self) def values(self) -> list[str]: """The labels of the prescribed actions, in the order of `keys`.""" - return [self._action_at(infoset) for infoset in self] - - def items(self) -> list[tuple[Infoset, str]]: - """(information set, action label) pairs, in the order of `keys`.""" - return [(infoset, self._action_at(infoset)) for infoset in self] + return [self._action_at(node) for node in self._reachable_nodes()] + + def items(self) -> list[tuple[tuple, str]]: + """(History, action label) pairs, in the order of `keys`.""" + return [ + (_canonical_history(node), self._action_at(node)) + for node in self._reachable_nodes() + ] @cython.cclass diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index 8a393eafae..a291672814 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -30,7 +30,7 @@ class _LabelSet: """Shared implementation for `StrategySupport` and `ActionSupport`: an immutable snapshot of a set of labels (strategies, or actions at an information set) taken from a support profile at retrieval time, together with the owner (a player label, - or an information set) the labels belong to. + or the History identifying an information set) the labels belong to. Not exported; only `StrategySupport` and `ActionSupport` are part of the public API. """ diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9d913b2efb..05c328e7ce 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -97,13 +97,12 @@ def efg_asymmetric_tree_text() -> str: 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.make_outcome(node, {"1": payoff[0], "2": payoff[1]}, node.prior_action.label) - for node in right.children: - game.make_outcome(node, {"1": 0, "2": 0}, node.prior_action.label) + game.append_move(gbt.H.path(), "1", ["L", "R"]) + game.append_move(gbt.H.path("L"), "2", ["x", "y", "z"]) + game.append_move(gbt.H.path("R"), "2", ["p", "q"]) + for label in ["x", "y", "z"]: + payoff = [1, 1] if label == "x" else [0, 0] + game.make_outcome(gbt.H.path("L", label), {"1": payoff[0], "2": payoff[1]}, label) + for label in ["p", "q"]: + game.make_outcome(gbt.H.path("R", label), {"1": 0, "2": 0}, label) return game.to_efg() diff --git a/tests/games.py b/tests/games.py index 7e2ee54f7f..657deae3bd 100644 --- a/tests/games.py +++ b/tests/games.py @@ -9,28 +9,144 @@ import pygambit as gbt -def all_infosets(game: gbt.Game) -> list[gbt.Infoset]: - """All Infosets belonging to a personal player, across every player, in the - canonical order `Game.infosets` used to yield before its removal (17.0.0).""" - return [n.infoset for p in game.players for n in game.get_infosets(p)] - - -def player_infosets(game: gbt.Game, player: str) -> list[gbt.Infoset]: - """All Infosets belonging to `player`, in canonical order, matching - `Player.infosets` before its removal (17.0.0).""" - return [n.infoset for n in game.get_infosets(player)] - - -def find_infoset(game: gbt.Game, player: str, label: str) -> gbt.Infoset: - """Find the Infoset belonging to `player` with the given label, matching - `Player.infosets[label]` before its removal (17.0.0).""" - return next(i for i in player_infosets(game, player) if i.label == label) - - -def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Infoset: - """Find the Infoset with the given label, searching across all (personal) - players, matching `Game.infosets[label]` before its removal (17.0.0).""" - return next(i for i in all_infosets(game) if i.label == label) +def all_infosets(game: gbt.Game) -> list[tuple]: + """The History of a representative member of every information set belonging + to a personal player, across every player, in the canonical order + `Game.infosets` used to yield before its removal (17.0.0). One History per + information set, matching `Game.get_infosets`.""" + return [h for p in game.players for h in game.get_infosets(p)] + + +def all_nodes(game: gbt.Game) -> list[gbt.Node]: + """Every node in the game, in depth-first traversal order, matching + `Game.nodes` before its removal (17.0.0).""" + return game._all_nodes() + + +def children_histories(game: gbt.Game, history: tuple) -> list[tuple]: + """The Histories of the children of the node at `history` -- the standard way + to enumerate a node's children now that `Node.children` is removed (17.0.0).""" + return [(*history, action) for action in game.get_actions(gbt.H.path(*history))] + + +def children_of(game: gbt.Game, history: tuple) -> list[gbt.Node]: + """The children of the node at `history`, as real Nodes -- for callers that + need actual `Node` objects (e.g. `.members`/`.player`), not just Histories.""" + return [node_at_history(game, h) for h in children_histories(game, history)] + + +def player_infosets(game: gbt.Game, player: str) -> list[tuple]: + """The History of a representative member of every information set belonging + to `player`, in canonical order, matching `Player.infosets` before its + removal (17.0.0).""" + return list(game.get_infosets(player)) + + +# (game.title, infoset label) -> the History of a member node, hand-verified against +# each fixture file before `Infoset.label` (and thus by-label lookup) was removed +# (17.0.0). `find_infoset`/`find_infoset_in_game` are a fixed stand-in for that +# removed lookup, recognizing only the fixture games this test suite happens to use +# it with; add an entry here if a new fixture/label combination is needed. +_INFOSET_LABEL_HISTORIES = { + ("Test Extensive Form Game", "Infoset 1:1"): (), + ("Test Extensive Form Game", "Infoset 2:1"): ("U1",), + ("Test Extensive Form Game", "Infoset 3:1"): ("U1", "U2"), + ("A simple Poker game", "Alice has King"): ("King",), + ("A simple Poker game", "Alice has Queen"): ("Queen",), + ("A simple Poker game", "Bob's response"): ("King", "Bet"), + ("Centipede game. Three inning with probability of altruism. ", "(1,1)"): + ("1=rational", "2=rational"), + ("Centipede game. Three inning with probability of altruism. ", "(1,3)"): + ("1=rational", "2=rational", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(1,5)"): + ("1=rational", "2=rational", "p", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(1,2)"): + ("1=altruist", "2=rational"), + ("Centipede game. Three inning with probability of altruism. ", "(1,4)"): + ("1=altruist", "2=rational", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(1,6)"): + ("1=altruist", "2=rational", "p", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,1)"): + ("1=rational", "2=rational", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,4)"): + ("1=rational", "2=rational", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,5)"): + ("1=rational", "2=rational", "p", "p", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,2)"): + ("1=rational", "2=altruist", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,3)"): + ("1=rational", "2=altruist", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,6)"): + ("1=rational", "2=altruist", "p", "p", "p", "p", "p"), + ("AM-driver variation", "Absent-minded"): (), + ("AM-driver variation", "Second"): ("1",), + ("AM-driver variation", "Third"): ("1", "1", "2"), + ("AM-game with two players", "Absent-minded"): (), + ("AM-game with two players", "Response 1"): ("1", "1"), + ("AM-game with two players", "Response 2"): ("1", "2"), + ("AM-game with two players", "Response 3"): ("2", "1"), + ("AM-game with two players", "Response 4"): ("2", "2"), + ("Untitled Extensive Game", "Absent-minded"): (), + ("Untitled Extensive Game", "Second"): ("1",), + ("Untitled Extensive Game", "Player 2"): ("1", "1", "2", "2"), +} + + +def node_at_history(game: gbt.Game, history: tuple) -> gbt.Node: + """The Node reached by following `history` (a tuple of action labels) from + the root -- the standard way to obtain a `Node` now that `Game.root` is + removed (17.0.0): `H.path(*history)` always resolves to exactly one node.""" + return game._get_nodes(gbt.H.path(*history))[0] + + +def find_infoset(game: gbt.Game, player: str, label: str) -> gbt.Node: + """The representative node of `player`'s information set historically identified + by `label`, matching `Player.infosets[label]` before its removal (17.0.0).""" + node = node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) + assert node.player == player + return node + + +def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Node: + """The representative node of the information set historically identified by + `label`, searching across all (personal) players, matching + `Game.infosets[label]` before its removal (17.0.0).""" + return node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) + + +def _node_history(node: gbt.Node) -> tuple: + """The plain-tuple history of `node`, walked via the private + `Node._parent`/`._prior_action`.""" + labels = [] + current = node + while current._parent() is not None: + labels.append(current._prior_action().label) + current = current._parent() + labels.reverse() + return tuple(labels) + + +def selector_for_nodes(nodes: list[gbt.Node]) -> gbt.Selector: + """A `Selector` matching exactly the given (possibly scattered, mixed-depth) + nodes -- for adapting fixtures that compute a `Node` list dynamically to the + `H`-only mutation methods.""" + histories = frozenset(_node_history(n) for n in nodes) + return gbt.H.after().filter(lambda h: h[:] in histories) + + +def selector_for_histories(histories: list[tuple]) -> gbt.Selector: + """A `Selector` matching exactly the given (possibly scattered, mixed-depth) + Histories -- the History-only counterpart to `selector_for_nodes`, for + fixtures that already work in terms of `History` rather than `Node`.""" + histories = frozenset(histories) + return gbt.H.after().filter(lambda h: h[:] in histories) + + +def selector_for_node(node: gbt.Node) -> gbt.Selector: + """A root-anchored `Selector` matching exactly `node`'s own path of action + labels -- for adapting a fixture's `Node` (e.g. one from `Game.get_infosets`) + to profile indexing, which is `Selector`-only.""" + return gbt.H.path(*_node_history(node)) # Label-validation fixtures. @@ -93,11 +209,10 @@ def create_efg_corresponding_to_bimatrix_game_arrays( g = gbt.Game.new_tree(players=["1", "2"], title=title) actions1 = [str(i) for i in range(m)] actions2 = [str(i) for i in range(n)] - g.append_move(g.root, "1", actions1) - g.append_move(g.root.children, "2", actions2) + g.append_move(gbt.H.path(), "1", actions1) + g.append_move(gbt.H.path(...), "2", actions2) for i, j in itertools.product(range(m), range(n)): - node = g.root.children[str(i)].children[str(j)] - g.make_outcome(node, {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") + g.make_outcome(gbt.H.path(str(i), str(j)), {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") return g @@ -143,9 +258,9 @@ def create_2x2_zero_sum_efg(variant: None | str = None) -> gbt.Game: g = create_efg_corresponding_to_bimatrix_game_arrays(A, B, title) if variant == "missing term outcome": - g.make_outcome_null(g.root.children["0"].children["1"]) + g.make_outcome_null(gbt.H.path("0", "1")) elif variant == "with neutral outcome": - g.make_outcome(g.root.children["0"], {"1": 0, "2": 0}, "neutral") + g.make_outcome(gbt.H.path("0"), {"1": 0, "2": 0}, "neutral") return g @@ -172,31 +287,26 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: poker from Reiley et al (2008).", ) deals = ["King", "Queen"] - g.append_event(g.root, deals, [gbt.Rational(1, 2)] * 2) + g.append_event(gbt.H.path(), dict.fromkeys(deals, gbt.Rational(1, 2))) - for node in g.root.children: - g.append_move(node, player="Alice", actions=["Bet", "Fold"]) + for card in deals: + g.append_move( + gbt.H.path(...).filter(lambda h, card=card: h[0] == card), + player="Alice", actions=["Bet", "Fold"] + ) - alice_bets_nodes = [ - g.root.children["King"].children["Bet"], - g.root.children["Queen"].children["Bet"], - ] - g.append_move(alice_bets_nodes, player="Bob", actions=["Call", "Fold"]) + g.append_move(gbt.H.path(..., "Bet"), player="Bob", actions=["Call", "Fold"]) - g.make_outcome(g.root, {"Alice": -1, "Bob": -1}, "Ante") + g.make_outcome(gbt.H.path(), {"Alice": -1, "Bob": -1}, "Ante") + g.make_outcome(gbt.H.path(..., "Fold"), {"Alice": 0, "Bob": 2}, "Alice Folds") + g.make_outcome(gbt.H.path(..., "Bet"), {"Alice": -1, "Bob": 0}, "Alice Bets") + g.make_outcome(gbt.H.path(..., "Bet", "Fold"), {"Alice": 3, "Bob": 0}, "Bob Folds") g.make_outcome( - [node.children["Fold"] for node in g.root.children], {"Alice": 0, "Bob": 2}, "Alice Folds" + gbt.H.path("King", "Bet", "Call"), {"Alice": 4, "Bob": -1}, "Bob Calls and Loses" ) g.make_outcome( - [node.children["Bet"] for node in g.root.children], {"Alice": -1, "Bob": 0}, "Alice Bets" + gbt.H.path("Queen", "Bet", "Call"), {"Alice": 0, "Bob": 3}, "Bob Calls and Wins" ) - g.make_outcome( - [node.children["Fold"] for node in alice_bets_nodes], {"Alice": 3, "Bob": 0}, "Bob Folds" - ) - bob_calls_and_loses_node = g.root.children["King"].children["Bet"].children["Call"] - g.make_outcome(bob_calls_and_loses_node, {"Alice": 4, "Bob": -1}, "Bob Calls and Loses") - bob_calls_and_wins_node = g.root.children["Queen"].children["Bet"].children["Call"] - g.make_outcome(bob_calls_and_wins_node, {"Alice": 0, "Bob": 3}, "Bob Calls and Wins") return g @@ -208,34 +318,31 @@ def _create_kuhn_poker_efg_without_outcomes(): cards = ["J", "Q", "K"] deals = ["JQ", "JK", "QJ", "QK", "KJ", "KQ"] - def deals_by_infoset(player, card): - player_idx = 0 if player == "Alice" else 1 - return [d for d in deals if d[player_idx] == card] - - g.append_event(g.root, deals, [gbt.Rational(1, 6)] * 6) + g.append_event(gbt.H.path(), dict.fromkeys(deals, gbt.Rational(1, 6))) for alice_card in cards: # Alice's first move - term_nodes = [g.root.children[d] for d in deals_by_infoset("Alice", alice_card)] - g.append_move(term_nodes, "Alice", ["Check", "Bet"]) + g.append_move( + gbt.H.path(...).filter(lambda h, card=alice_card: h[0][0] == card), + "Alice", ["Check", "Bet"] + ) for bob_card in cards: # Bob's move after Alice checks - term_nodes = [ - g.root.children[d].children["Check"] for d in deals_by_infoset("Bob", bob_card) - ] - g.append_move(term_nodes, "Bob", ["Check", "Bet"]) + g.append_move( + gbt.H.path(..., "Check").filter(lambda h, card=bob_card: h[0][1] == card), + "Bob", ["Check", "Bet"] + ) for alice_card in cards: # Alice's move if Bob's second action is bet - term_nodes = [ - g.root.children[d].children["Check"].children["Bet"] - for d in deals_by_infoset("Alice", alice_card) - ] - g.append_move(term_nodes, "Alice", ["Fold", "Call"]) + g.append_move( + gbt.H.path(..., "Check", "Bet").filter(lambda h, card=alice_card: h[0][0] == card), + "Alice", ["Fold", "Call"] + ) for bob_card in cards: # Bob's move after Alice bets initially - term_nodes = [ - g.root.children[d].children["Bet"] for d in deals_by_infoset("Bob", bob_card) - ] - g.append_move(term_nodes, "Bob", ["Fold", "Call"]) + g.append_move( + gbt.H.path(..., "Bet").filter(lambda h, card=bob_card: h[0][1] == card), + "Bob", ["Fold", "Call"] + ) return g @@ -260,9 +367,9 @@ def _create_kuhn_poker_efg_only_term_outcomes() -> gbt.Game: def calculate_payoffs(term_node): def get_path(node): path = [] - while node.parent: - path.append(node.prior_action.label) - node = node.parent + while node._parent(): + path.append(node._prior_action().label) + node = node._parent() return path def showdown(deal, payoffs, pot): @@ -306,11 +413,14 @@ def bet(player, payoffs, pot): (-2, 2): "BOb wins 2", } nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in all_nodes(g) if not g.get_actions(selector_for_node(n))]: nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): - g.make_outcome(nodes, {"Alice": payoffs[0], "Bob": payoffs[1]}, payoff_labels[payoffs]) + g.make_outcome( + selector_for_nodes(nodes), {"Alice": payoffs[0], "Bob": payoffs[1]}, + payoff_labels[payoffs] + ) return g @@ -334,14 +444,14 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: payoffs_by_key[f"{player} calls and loses"] = (-1, 4) if player == "Alice" else (4, -1) nodes_by_key = {key: [] for key in payoffs_by_key} - nodes_by_key["Ante"].append(g.root) + nodes_by_key["Ante"].append(node_at_history(g, ())) def collect_nodes(term_node): def get_path(node): path = [] - while node.parent: - path.append((node, node.prior_action.label)) - node = node.parent + while node._parent(): + path.append((node, node._prior_action().label)) + node = node._parent() return path path = get_path(term_node) @@ -370,14 +480,16 @@ def get_path(node): tmp = "wins" if winner == "Bob" else "loses" nodes_by_key[f"Bob calls and {tmp}"].append(n) - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in all_nodes(g) if not g.get_actions(selector_for_node(n))]: collect_nodes(term_node) for key, nodes in nodes_by_key.items(): # the same non-terminal node is revisited once per terminal descendant walked above deduped_nodes = list(dict.fromkeys(nodes)) alice_payoff, bob_payoff = payoffs_by_key[key] - g.make_outcome(deduped_nodes, {"Alice": alice_payoff, "Bob": bob_payoff}, key) + g.make_outcome( + selector_for_nodes(deduped_nodes), {"Alice": alice_payoff, "Bob": bob_payoff}, key + ) return g @@ -448,24 +560,24 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: g = gbt.Game.new_tree( players=["Buyer", "Seller"], title="One-shot trust game, after Kreps (1990)" ) - g.append_move(g.root, "Buyer", ["Trust", "Not trust"]) - g.append_move(g.root.children["Trust"], "Seller", ["Honor", "Abuse"]) + g.append_move(gbt.H.path(), "Buyer", ["Trust", "Not trust"]) + g.append_move(gbt.H.path("Trust"), "Seller", ["Honor", "Abuse"]) g.make_outcome( - g.root.children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" + gbt.H.path("Trust", "Honor"), {"Buyer": 1, "Seller": 1}, "Trustworthy" ) if unique_NE_variant: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + gbt.H.path("Trust", "Abuse"), {"Buyer": "1/2", "Seller": 2}, "Untrustworthy", ) else: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + gbt.H.path("Trust", "Abuse"), {"Buyer": -1, "Seller": 2}, "Untrustworthy", ) - g.make_outcome(g.root.children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") + g.make_outcome(gbt.H.path("Not trust"), {"Buyer": 0, "Seller": 0}, "Opt-out") return g @@ -575,24 +687,24 @@ def __init__(self, params): def gbt_game(self): g = gbt.Game.new_tree(players=["1", "2"], title=f"Centipede Game with {self.N} rounds") - current_node = g.root current_player = "1" for t in range(self.N): - g.append_move(current_node, current_player, ["Take", "Push"]) + g.append_move(gbt.H.path(*(["Push"] * t)), current_player, ["Take", "Push"]) payoffs = [2**t * self.m0, 2**t * self.m1] # take payoffs if current_player == "2": payoffs.reverse() g.make_outcome( - current_node.children["Take"], {"1": payoffs[0], "2": payoffs[1]}, f"take_{t}" + gbt.H.path(*(["Push"] * t), "Take"), {"1": payoffs[0], "2": payoffs[1]}, + f"take_{t}" ) if t == self.N - 1: # for last round, push payoffs payoffs = [2 ** (t + 1) * self.m1, 2 ** (t + 1) * self.m0] if current_player == "2": payoffs.reverse() g.make_outcome( - current_node.children["Push"], {"1": payoffs[0], "2": payoffs[1]}, f"push_{t}" + gbt.H.path(*(["Push"] * (t + 1))), {"1": payoffs[0], "2": payoffs[1]}, + f"push_{t}" ) - current_node = current_node.children["Push"] current_player = "2" if current_player == "1" else "1" return g @@ -709,33 +821,34 @@ def reduced_strategies(self): self.set_size_of_rsf(rs) return rs - def create_binary_tree(self, g, node, whose_turn, depth, max_depth): + def create_binary_tree(self, g, path, whose_turn, depth, max_depth): # whose_turn cycles through 0,1,n_players-1; current player is str(whose_turn + 1) if depth == max_depth: g.make_outcome( - node, {str(p): 0 for p in self.players}, f"leaf_{len(list(g.outcomes))}" + gbt.H.path(*path), {str(p): 0 for p in self.players}, + f"leaf_{len(g.get_outcomes())}" ) else: current_player = str(whose_turn + 1) - g.append_move(node, current_player, ["L", "R"]) + g.append_move(gbt.H.path(*path), current_player, ["L", "R"]) whose_turn = (whose_turn + 1) % self.n_players - for child in node.children: - self.create_binary_tree(g, child, whose_turn, depth + 1, max_depth) + for label in ["L", "R"]: + self.create_binary_tree(g, (*path, label), whose_turn, depth + 1, max_depth) def gbt_game(self): g = gbt.Game.new_tree( players=[str(p) for p in self.players], title=f"Binary Tree Game (L={self.level})", ) - self.create_binary_tree(g, g.root, 0, 0, self.level) - for n in g.nodes: - if not n.is_terminal and not n.children["L"].is_terminal: - left = n.children["L"] + self.create_binary_tree(g, (), 0, 0, self.level) + for n in all_nodes(g): + history = _node_history(n) + if g.get_actions(gbt.H.path(*history)) and g.get_actions(gbt.H.path(*history, "L")): + left, right = children_of(g, history) g.make_infoset( - list(left.infoset.members) + [n.children["R"]], - left.infoset.player, - left.infoset.label or None, + selector_for_nodes(list(left.members) + [right]), + left.player, ) return g diff --git a/tests/test_actions.py b/tests/test_actions.py deleted file mode 100644 index 970f21b08c..0000000000 --- a/tests/test_actions.py +++ /dev/null @@ -1,358 +0,0 @@ -import pytest - -import pygambit as gbt - -from . import games - - -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_action_label(label: str): - game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) - game.relabel_actions(game.root, {action: label}) - assert label in game.root.actions - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_action_label_invalid_raises_valueerror(label: str): - game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) - with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: label}) - - -def test_relabel_action_empty_raises_valueerror(): - game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) - with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: ""}) - - -def test_relabel_actions_duplicate_raises_valueerror(): - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.relabel_actions(game.root, {"King": "Queen"}) - - -def test_relabel_actions_simultaneous_swap(): - """Reassignment is simultaneous, so a swap is well-defined; applying the entries one - at a time would collide on the intermediate state. - """ - game = games.create_stripped_down_poker_efg() - game.relabel_actions(game.root, {"King": "Queen", "Queen": "King"}) - assert list(game.root.event.actions) == ["Queen", "King"] - - -def test_relabel_actions_duplicate_targets_raises_valueerror(): - """Both replacements are free of the actions left untouched but collide with each - other, so checking each against the untouched actions alone would let this through. - """ - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.relabel_actions(game.root, {"King": "Ace", "Queen": "Ace"}) - - -def test_relabel_actions_unknown_label_raises_keyerror(): - game = games.create_stripped_down_poker_efg() - with pytest.raises(KeyError): - game.relabel_actions(game.root, {"Jack": "Ace"}) - - -def test_relabel_actions_unknown_label_not_strict_is_ignored(): - game = games.create_stripped_down_poker_efg() - game.relabel_actions(game.root, {"Jack": "Ace", "King": "Ace"}, strict=False) - assert list(game.root.event.actions) == ["Ace", "Queen"] - - -def test_relabel_actions_failure_leaves_game_unchanged(): - """The whole mapping is validated before any label is written, so a mapping that - fails part way through leaves no partial reassignment behind. - """ - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.relabel_actions(game.root, {"King": "Ace", "Queen": ""}) - assert list(game.root.event.actions) == ["King", "Queen"] - - -def test_relabel_actions_scope_is_the_information_set(): - """Action labels are unique within an information set, not within a player: Alice's - two information sets both offer "Bet", and relabelling one leaves the other untouched - and free to take the same new label. - """ - game = games.create_stripped_down_poker_efg() - king = games.find_infoset(game, "Alice", "Alice has King") - queen = games.find_infoset(game, "Alice", "Alice has Queen") - game.relabel_actions(next(iter(king.members)), {"Bet": "Raise"}) - assert list(king.actions) == ["Raise", "Fold"] - assert list(queen.actions) == ["Bet", "Fold"] - game.relabel_actions(next(iter(queen.members)), {"Bet": "Raise"}) - assert list(queen.actions) == ["Raise", "Fold"] - - -def test_relabel_actions_not_a_mapping_raises_typeerror(): - game = games.create_stripped_down_poker_efg() - with pytest.raises(TypeError): - game.relabel_actions(game.root, [("King", "Queen")]) - - -@pytest.mark.parametrize("labels", [{1: "Queen"}, {"King": 1}]) -def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): - game = games.create_stripped_down_poker_efg() - with pytest.raises(TypeError): - game.relabel_actions(game.root, labels) - - -def test_set_move_actions_drop_shrinks_actions_and_children(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - action_count = len(infoset.actions) - remaining = list(infoset.actions)[1:] - game.set_move_actions(node, remaining, drop=True) - assert len(infoset.actions) == action_count - 1 - assert len(node.children) == action_count - 1 - - -def test_set_move_actions_cannot_remove_the_only_action(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - last = next(iter(infoset.actions)) - game.set_move_actions(node, [last], drop=True) - assert list(infoset.actions) == [last] - with pytest.raises(gbt.UndefinedOperationError): - game.set_move_actions(node, [], drop=True) - - -def test_set_move_actions_reorder_carries_subtrees(): - """Reordering three actions as a cycle moves every action to a new position. - Each action carries its whole subtree with it, at every member of the information set.""" - game = gbt.Game.new_tree(players=["Alice", "Bob"]) - game.append_move(game.root, "Bob", ["x", "y"]) - game.append_move(list(game.root.children), "Alice", ["a", "b", "c"]) - game.append_move([game.root.children["x"].children["a"], - game.root.children["y"].children["b"]], "Bob", ["l", "r"]) - infoset = game.root.children["x"].infoset - members = list(infoset.members) - children_before = [{label: member.children[label] for label in ("a", "b", "c")} - for member in members] - game.set_move_actions(game.root.children["x"], ["c", "a", "b"]) - assert list(infoset.actions) == ["c", "a", "b"] - for member, children in zip(members, children_before, strict=True): - assert list(member.children) == [children["c"], children["a"], children["b"]] - - -def test_set_move_actions_add_drop_and_reorder_together(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - nodes_before = len(game.nodes) - game.set_move_actions(node, ["Raise", "Fold"], drop=True) - assert list(infoset.actions) == ["Raise", "Fold"] - # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. - assert len(game.nodes) == nodes_before - 3 + 1 - assert len(games.find_infoset(game, "Bob", "Bob's response").members) == 1 - - -def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - before = game.to_efg() - with pytest.raises(ValueError): - game.set_move_actions(node, ["Bet"]) - with pytest.raises(ValueError): - game.set_move_actions(node, ["Bet", "Fold", "Raise"], add=False) - assert game.to_efg() == before - - -def test_set_move_actions_raises_at_an_event(): - """`set_move_actions` is only for a personal player's move; `set_event_actions` is the - corresponding operation for an event.""" - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.set_move_actions(game.root, ["King", "Queen"]) - - -@pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) -def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): - """Duplicate, empty, and invalid labels in `actions` are rejected in C++, - after the Python guards pass; the game must be unmodified by the failure.""" - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - before = game.to_efg() - with pytest.raises(ValueError): - game.set_move_actions(node, bad_labels, drop=True) - assert game.to_efg() == before - - -def test_set_move_actions_absent_minded_drop_and_add(): - """Dropping an action whose subtree contains another member of the same information - set deletes that member with the subtree.""" - game = gbt.Game.new_tree(players=["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) - game.append_infoset(game.root.children["a"], game.root) - game.set_move_actions(game.root, ["b", "c"], drop=True) - assert list(game.root.infoset.actions) == ["b", "c"] - assert len(game.root.infoset.members) == 1 - assert len(game.nodes) == 3 - - -def test_set_event_actions_reorder_carries_probabilities(): - game = games.create_stripped_down_poker_efg() - game.set_event_actions(game.root, {"King": "3/4", "Queen": "1/4"}) - game.set_event_actions(game.root, {"Queen": "1/4", "King": "3/4"}) - assert list(game.root.actions) == ["Queen", "King"] - assert game.root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} - - -def test_set_event_actions_add_with_probs_mapping(): - game = games.create_stripped_down_poker_efg() - nodes_before = len(game.nodes) - game.set_event_actions(game.root, {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) - assert list(game.root.actions) == ["Jack", "King", "Queen"] - assert game.root.action_probs == { - "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) - } - assert len(game.nodes) == nodes_before + 1 - - -def test_set_event_actions_drop_with_probs_mapping(): - game = games.create_stripped_down_poker_efg() - game.set_event_actions(game.root, {"King": 1}, drop=True) - assert list(game.root.actions) == ["King"] - assert game.root.action_probs == {"King": 1} - - -def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): - game = games.create_stripped_down_poker_efg() - _ = game.root.event - before = game.to_efg() - with pytest.raises(ValueError): - game.set_event_actions(game.root, {"King": 1}) - with pytest.raises(ValueError): - game.set_event_actions( - game.root, {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False - ) - assert game.to_efg() == before - - -def test_set_event_actions_raises_at_a_move(): - """`set_event_actions` is only for an event; `set_move_actions` is the corresponding - operation for a personal player's move.""" - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - with pytest.raises(ValueError): - game.set_event_actions(next(iter(infoset.members)), {"Bet": 1}) - - -def test_set_event_actions_rejects_non_mapping_probs(): - """`probs` must be a mapping: with no separate list of actions, there's nothing for a - plain sequence of probabilities to be paired with positionally.""" - game = games.create_stripped_down_poker_efg() - before = game.to_efg() - with pytest.raises(TypeError): - game.set_event_actions(game.root, ["3/4", "1/4"]) - assert game.to_efg() == before - - -def test_set_event_actions_bad_distribution_raises_valueerror(): - game = games.create_stripped_down_poker_efg() - before = game.to_efg() - with pytest.raises(ValueError): - game.set_event_actions(game.root, {"King": "3/4", "Queen": "3/4"}) - assert game.to_efg() == before - - -@pytest.mark.parametrize( - "game, player_label, strategy_label, infoset_path, expected_action_label", - [ - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "1", [], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "2", [], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "1", ["R"], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "2", ["R"], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "1", ["R", "L"], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "2", ["R", "L"], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", [], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "2", [], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "1", ["L"], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "2", ["L"], "L"), - (games.read_from_file("basic_extensive_game.efg"), "Player 1", "1", [], "U1"), - (games.read_from_file("basic_extensive_game.efg"), "Player 1", "2", [], "D1"), - (games.read_from_file("basic_extensive_game.efg"), "Player 2", "1", ["U1"], "U2"), - (games.read_from_file("basic_extensive_game.efg"), "Player 2", "2", ["U1"], "D2"), - (games.read_from_file("basic_extensive_game.efg"), "Player 3", "1", ["U1", "U2"], "U3"), - (games.read_from_file("basic_extensive_game.efg"), "Player 3", "2", ["U1", "U2"], "D3"), - ], -) -def test_get_behavior_prescribed_action_defined( - game, player_label, strategy_label, infoset_path, expected_action_label -): - """Verify `Game.get_behavior` retrieves the correct action for defined actions.""" - node = game.root - for action_label in infoset_path: - node = node.children[action_label] - infoset = node.infoset - - prescribed_action = game.get_behavior(player_label, strategy_label).get(infoset) - - assert prescribed_action == expected_action_label - - -@pytest.mark.parametrize( - "game, player_label, strategy_label, infoset_label, infoset_path", - [ - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", None, ["L", "L"]), - (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,3)", None), - (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,5)", None), - (games.read_from_file("cent3.efg"), "Player 1", "2", "(1,5)", None), - (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), - (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), - (games.read_from_file("cent3.efg"), "Player 2", "2", "(2,5)", None), - ], -) -def test_get_behavior_prescribed_action_undefined_returns_none( - game, player_label, strategy_label, infoset_label, infoset_path -): - """Verify `Game.get_behavior` returns None when called on an unreached player's infoset""" - if infoset_label is not None: - infoset = games.find_infoset_in_game(game, infoset_label) - else: - node = game.root - for action_label in infoset_path: - node = node.children[action_label] - infoset = node.infoset - - prescribed_action = game.get_behavior(player_label, strategy_label).get(infoset) - - assert prescribed_action is None - - -@pytest.mark.parametrize( - "game, player_label, other_infoset_path", - [ - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", ["R"]), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", []), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", ["L"]), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", []), - (games.read_from_file("basic_extensive_game.efg"), "Player 1", ["U1"]), - (games.read_from_file("basic_extensive_game.efg"), "Player 2", ["U1", "U2"]), - (games.read_from_file("basic_extensive_game.efg"), "Player 3", []), - ], -) -def test_get_behavior_raises_value_error_for_wrong_player( - game, player_label, other_infoset_path -): - """ - Verify `Game.get_behavior`'s result raises ValueError when the infoset belongs - to a different player than the strategy. - """ - behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) - node = game.root - for action_label in other_infoset_path: - node = node.children[action_label] - other_players_infoset = node.infoset - - with pytest.raises(ValueError): - behavior.get(other_players_infoset) diff --git a/tests/test_behav.py b/tests/test_behav.py index 6beeb78aad..5513e212b5 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -18,9 +18,27 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f """ convert = (lambda p: gbt.Rational(p)) if rational_flag else (lambda p: p) probs_iter = iter(probs) - for infoset in games.all_infosets(profile.game): - node = next(iter(infoset.members)) - profile[node] = {a: convert(next(probs_iter)) for a in infoset.actions} + game = profile.game + for infoset in games.all_infosets(game): + selector = gbt.H.path(*infoset) + profile[selector] = { + a: convert(next(probs_iter)) for a in game.get_actions(selector) + } + + +def _infoset_history(game: gbt.Game, label: str) -> tuple: + """The History of the representative member of the infoset historically + identified by `label`, matching the removed `Infoset`'s by-label lookup.""" + return games._INFOSET_LABEL_HISTORIES[(game.title, label)] + + +def _member_selector(game: gbt.Game, label: str) -> gbt.Selector: + """A `Selector` for some member of the infoset historically identified by + `label` -- not necessarily the representative one, so profile indexing is + verified to work from any member, not just the canonical one.""" + history = _infoset_history(game, label) + member = next(iter(game.get_members(gbt.H.path(*history)))) + return gbt.H.path(*member) @pytest.mark.parametrize( @@ -39,46 +57,6 @@ def test_payoffs_reference(game: gbt.Game, rational_flag: bool, payoffs: tuple): assert profile.payoffs[player] == payoff -@pytest.mark.parametrize( - "game,rational_flag", - [ - (games.read_from_file("mixed_behavior_game.efg"), False), - (games.read_from_file("mixed_behavior_game.efg"), True), - (games.create_stripped_down_poker_efg(), False), - (games.create_stripped_down_poker_efg(), True), - ], -) -def test_is_defined_at(game: gbt.Game, rational_flag: bool): - profile = game.mixed_behavior_profile(rational=rational_flag) - for infoset in games.all_infosets(game): - assert profile.is_defined_at(next(iter(infoset.members))) - - -@pytest.mark.parametrize( - "game,label,rational_flag", - [ - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 1:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 2:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 3:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 1:1", True), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 2:1", True), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 3:1", True), - (games.create_stripped_down_poker_efg(), "Alice has King", False), - (games.create_stripped_down_poker_efg(), "Alice has Queen", False), - (games.create_stripped_down_poker_efg(), "Bob's response", False), - (games.create_stripped_down_poker_efg(), "Alice has King", True), - (games.create_stripped_down_poker_efg(), "Alice has Queen", True), - (games.create_stripped_down_poker_efg(), "Bob's response", True), - ], -) -def test_is_defined_at_by_label(game: gbt.Game, label: str, rational_flag: bool): - """is_defined_at resolves a string as a node's own label, not an infoset's label.""" - node = next(iter(games.find_infoset_in_game(game, label).members)) - node.label = "target" - profile = game.mixed_behavior_profile(rational=rational_flag) - assert profile.is_defined_at(node.label) - - @pytest.mark.parametrize( "game,player_label,infoset_label,action_label,prob,rational_flag", [ @@ -201,10 +179,10 @@ def test_profile_indexing_by_player_infoset_action_reference( rational_flag: bool, ): profile = game.mixed_behavior_profile(rational=rational_flag) - infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = _infoset_history(game, infoset_label) + assert game.get_player(gbt.H.path(*history)) == player_label prob = gbt.Rational(prob) if rational_flag else prob - assert profile[node][action_label] == prob + assert profile[_member_selector(game, infoset_label)][action_label] == prob @pytest.mark.parametrize( @@ -260,17 +238,19 @@ def test_profile_indexing_by_player_infoset_action_reference( (games.create_stripped_down_poker_efg(), "Bob", "Bob's response", ["1/2", "1/2"], True), ], ) -def test_profile_indexing_by_node_reference( +def test_profile_indexing_by_selector_reference( game: gbt.Game, player_label: str, infoset_label: str, probs: list, rational_flag: bool ): - """profile[node] and profile[player_label][node] resolve to the same MixedAction.""" + """profile[selector] and profile[player_label][selector] resolve to the same + MixedAction.""" profile = game.mixed_behavior_profile(rational=rational_flag) - infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = _infoset_history(game, infoset_label) + assert game.get_player(gbt.H.path(*history)) == player_label + selector = _member_selector(game, infoset_label) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs - expected = dict(zip(infoset.actions, probs, strict=True)) - assert profile[player_label][node] == expected - assert profile[node] == expected + expected = dict(zip(game.get_actions(gbt.H.path(*history)), probs, strict=True)) + assert profile[player_label][selector] == expected + assert profile[selector] == expected @pytest.mark.parametrize( @@ -280,17 +260,17 @@ def test_profile_indexing_by_node_reference( (games.create_stripped_down_poker_efg(), "Alice", "Bob"), ], ) -def test_behavior_indexing_rejects_node_from_different_player( +def test_behavior_indexing_rejects_selector_from_different_player( game: gbt.Game, player_label: str, other_player_label: str ): - """MixedBehavior/MixedBehaviorProfile reject a Node whose information set belongs to a + """MixedBehavior rejects a Selector whose information set belongs to a different player than the one being indexed. """ profile = game.mixed_behavior_profile() other_infoset = games.player_infosets(game, other_player_label)[0] - other_node = next(iter(other_infoset.members)) + other_selector = gbt.H.path(*other_infoset) with pytest.raises(gbt.MismatchError): - profile[player_label][other_node] + profile[player_label][other_selector] @pytest.mark.parametrize( @@ -315,7 +295,7 @@ def test_profile_indexing_by_player_label_reference( if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] expected = [ - dict(zip(infoset.actions, probs, strict=True)) + dict(zip(game.get_actions(gbt.H.path(*infoset)), probs, strict=True)) for infoset, probs in zip( games.player_infosets(game, player_label), behav_data, strict=True ) @@ -358,9 +338,9 @@ def test_set_probabilities_action( """A sparse one-action distribution leaves the infoset's other actions at weight zero.""" profile = game.mixed_behavior_profile(rational=rational_flag) prob = gbt.Rational(prob) if rational_flag else prob - node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - profile[node] = {action_label: prob} - assert profile[node][action_label] == prob + selector = _member_selector(game, infoset_label) + profile[selector] = {action_label: prob} + assert profile[selector][action_label] == prob @pytest.mark.parametrize( @@ -434,11 +414,12 @@ def test_set_probabilities_infoset( profile = game.mixed_behavior_profile(rational=rational_flag) if rational_flag: probs = [gbt.Rational(p) for p in probs] - infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) - expected = dict(zip(infoset.actions, probs, strict=True)) - profile[node] = expected - assert profile[node] == expected + history = _infoset_history(game, infoset_label) + assert game.get_player(gbt.H.path(*history)) == player_label + selector = _member_selector(game, infoset_label) + expected = dict(zip(game.get_actions(gbt.H.path(*history)), probs, strict=True)) + profile[selector] = expected + assert profile[selector] == expected @pytest.mark.parametrize( @@ -467,7 +448,7 @@ def test_set_probabilities_player_by_label( if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] expected = [ - dict(zip(infoset.actions, probs, strict=True)) + dict(zip(game.get_actions(gbt.H.path(*infoset)), probs, strict=True)) for infoset, probs in zip( games.player_infosets(game, player_label), behav_data, strict=True ) @@ -475,103 +456,106 @@ def test_set_probabilities_player_by_label( for infoset, distribution in zip( games.player_infosets(game, player_label), expected, strict=True ): - profile[next(iter(infoset.members))] = distribution + profile[gbt.H.path(*infoset)] = distribution assert profile[player_label] == expected -def _p1_node(game: gbt.Game): - return next(iter(games.player_infosets(game, "Player 1")[0].members)) +def _p1_selector(game: gbt.Game) -> gbt.Selector: + """A Selector for Player 1's first information set.""" + return gbt.H.path(*games.player_infosets(game, "Player 1")[0]) def test_behavior_setitem_allows_sparse_distribution(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) - profile[node] = {"U1": 1} - assert profile[node] == {"U1": 1, "D1": 0} + selector = _p1_selector(game) + profile[selector] = {"U1": 1} + assert profile[selector] == {"U1": 1, "D1": 0} def test_set_mixed_action_sparse_matches_setitem(): game = games.read_from_file("mixed_behavior_game.efg") - node = _p1_node(game) + selector = _p1_selector(game) sparse_profile = game.mixed_behavior_profile() - sparse_profile.set_mixed_action(node, {"U1": 1}, sparse=True) + sparse_profile.set_mixed_action(selector, {"U1": 1}, sparse=True) setitem_profile = game.mixed_behavior_profile() - setitem_profile[node] = {"U1": 1} - assert sparse_profile[node] == setitem_profile[node] + setitem_profile[selector] = {"U1": 1} + assert sparse_profile[selector] == setitem_profile[selector] def test_set_mixed_action_defaults_to_requiring_every_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = _p1_selector(game) with pytest.raises(ValueError, match="exactly one weight"): - profile.set_mixed_action(node, {"U1": 1}) + profile.set_mixed_action(selector, {"U1": 1}) @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_unknown_action_label(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = _p1_selector(game) with pytest.raises(ValueError, match="not an action label"): - profile.set_mixed_action(node, {"not-an-action": 1}, sparse=sparse) + profile.set_mixed_action(selector, {"not-an-action": 1}, sparse=sparse) with pytest.raises(ValueError, match="not an action label"): - profile[node] = {"not-an-action": 1} + profile[selector] = {"not-an-action": 1} def test_behavior_setitem_empty_distribution_is_all_zero_error(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = _p1_selector(game) with pytest.raises(ValueError, match="zero"): - profile[node] = {} + profile[selector] = {} @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_non_mapping(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = _p1_selector(game) with pytest.raises(TypeError, match="Mapping"): - profile.set_mixed_action(node, [1, 0], sparse=sparse) + profile.set_mixed_action(selector, [1, 0], sparse=sparse) with pytest.raises(TypeError, match="Mapping"): - profile[node] = [1, 0] + profile[selector] = [1, 0] @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_uncoercible_weight(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = _p1_selector(game) full_distribution = {"U1": "abc", "D1": 0} with pytest.raises(ValueError, match="convert"): - profile.set_mixed_action(node, full_distribution, sparse=sparse) + profile.set_mixed_action(selector, full_distribution, sparse=sparse) with pytest.raises(ValueError, match="convert"): - profile[node] = full_distribution + profile[selector] = full_distribution def test_behavior_setitem_sparse_rejects_negative_weight(): """Negativity is checked even for weights given under a sparse distribution.""" game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = _p1_selector(game) with pytest.raises(ValueError, match="negative"): - profile[node] = {"U1": -1} + profile[selector] = {"U1": -1} @pytest.mark.parametrize("sparse", [False, True]) -def test_behavior_indexing_rejects_infoset_object(sparse: bool): - """MixedBehaviorProfile's indexing is Node-only; an Infoset object is rejected.""" +def test_behavior_indexing_rejects_history(sparse: bool): + """MixedBehaviorProfile's indexing is Selector-only; a bare `History` tuple is + not accepted, following the same pattern as `Game.get_minimal_subgame`. + """ game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - infoset = games.player_infosets(game, "Player 1")[0] + history = games.player_infosets(game, "Player 1")[0] with pytest.raises(TypeError): - profile[infoset] + profile[history] with pytest.raises(TypeError): - profile[infoset] = {"U1": 1} + profile[history] = {"U1": 1} with pytest.raises(TypeError): - profile.set_mixed_action(infoset, {"U1": 1}, sparse=sparse) + profile.set_mixed_action(history, {"U1": 1}, sparse=sparse) @pytest.mark.parametrize("rational_flag", [False, True]) @@ -581,37 +565,37 @@ def test_mixed_action_and_behavior_are_frozen_snapshots(rational_flag: bool): """ game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - action_before = profile[node] + selector = _p1_selector(game) + action_before = profile[selector] behavior_before = profile["Player 1"] - profile[node] = {"U1": 1, "D1": 0} + profile[selector] = {"U1": 1, "D1": 0} assert dict(action_before) == {"U1": 0.5, "D1": 0.5} - assert dict(behavior_before[node]) == {"U1": 0.5, "D1": 0.5} - assert dict(profile[node]) == {"U1": 1, "D1": 0} + assert dict(behavior_before[selector]) == {"U1": 0.5, "D1": 0.5} + assert dict(profile[selector]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_copy_does_not_affect_original(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - original_before = dict(original[node]) + selector = _p1_selector(game) + original_before = dict(original[selector]) copy = original.copy() - copy[node] = {"U1": 1, "D1": 0} - assert dict(original[node]) == original_before - assert dict(copy[node]) == {"U1": 1, "D1": 0} + copy[selector] = {"U1": 1, "D1": 0} + assert dict(original[selector]) == original_before + assert dict(copy[selector]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_original_does_not_affect_copy(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) + selector = _p1_selector(game) copy = original.copy() - copy_before = dict(copy[node]) - original[node] = {"U1": 1, "D1": 0} - assert dict(copy[node]) == copy_before - assert dict(original[node]) == {"U1": 1, "D1": 0} + copy_before = dict(copy[selector]) + original[selector] = {"U1": 1, "D1": 0} + assert dict(copy[selector]) == copy_before + assert dict(original[selector]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) @@ -624,20 +608,20 @@ def test_as_float_returns_double(rational_flag: bool): def test_as_float_converts_rational_probabilities(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=True) - node = _p1_node(game) - profile[node] = {"U1": "1/3", "D1": "2/3"} + selector = _p1_selector(game) + profile[selector] = {"U1": "1/3", "D1": "2/3"} result = profile.as_float() - assert dict(result[node]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} + assert dict(result[selector]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} def test_as_float_is_independent_copy(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=False) - node = _p1_node(game) + selector = _p1_selector(game) result = profile.as_float() assert result == profile - result[node] = {"U1": 1.0, "D1": 0.0} - assert dict(profile[node]) != dict(result[node]) + result[selector] = {"U1": 1.0, "D1": 0.0} + assert dict(profile[selector]) != dict(result[selector]) @pytest.mark.parametrize( @@ -700,14 +684,10 @@ def test_as_float_is_independent_copy(): def test_realiz_prob_nodes_reference( game: gbt.Game, path: list[str], realiz_prob: str | float, rational_flag: bool ): - # nodes have no labels, so each node is reached by walking the action-label - # path from the root (an empty path is the root itself) + # a node's History is the tuple of action labels from the root (empty for the root) profile = game.mixed_behavior_profile(rational=rational_flag) realiz_prob = gbt.Rational(realiz_prob) if rational_flag else realiz_prob - node = game.root - for action_label in path: - node = node.children[action_label] - assert profile.realiz_probs[node] == realiz_prob + assert profile.realiz_probs[tuple(path)] == realiz_prob @pytest.mark.parametrize( @@ -723,7 +703,7 @@ def test_infoset_probs_reference(game: gbt.Game, rational_flag: bool, infoset_pr profile = game.mixed_behavior_profile(rational=rational_flag) for prob, infoset in zip(infoset_probs, games.all_infosets(game), strict=True): prob = gbt.Rational(prob) if rational_flag else prob - assert profile.infoset_probs[next(iter(infoset.members))] == prob + assert profile.infoset_probs[gbt.H.path(*infoset)] == prob @pytest.mark.parametrize( @@ -761,8 +741,7 @@ def test_absent_minded_infoset_prob( game: gbt.Game, infoset_label: str, prob: str | float, rational_flag: bool ): profile = game.mixed_behavior_profile(rational=rational_flag) - node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - ip = profile.infoset_probs[node] + ip = profile.infoset_probs[_member_selector(game, infoset_label)] assert ip == (gbt.Rational(prob) if rational_flag else prob) @@ -772,8 +751,8 @@ def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): game = gbt.catalog.load("journals/geb/gilboa1997/fig2") profile = game.mixed_behavior_profile(rational=rational_flag) one = gbt.Rational(1) if rational_flag else 1.0 - assert profile.realiz_probs[game.root] == one - assert profile.infoset_probs[game.root] == one + assert profile.realiz_probs[()] == one + assert profile.infoset_probs[gbt.H.path()] == one @pytest.mark.parametrize( @@ -789,7 +768,7 @@ def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_v profile = game.mixed_behavior_profile(rational=rational_flag) for payoff, infoset in zip(infoset_values, games.all_infosets(game), strict=True): payoff = gbt.Rational(payoff) if rational_flag else payoff - assert profile.infoset_values[next(iter(infoset.members))] == payoff + assert profile.infoset_values[gbt.H.path(*infoset)] == payoff @pytest.mark.parametrize( @@ -812,8 +791,11 @@ def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_v def test_action_values_reference(game: gbt.Game, rational_flag: bool, action_values: tuple): profile = game.mixed_behavior_profile(rational=rational_flag) for values_for_infoset, infoset in zip(action_values, games.all_infosets(game), strict=True): - infoset_action_values = profile.action_values[next(iter(infoset.members))] - for value, action in zip(values_for_infoset, infoset.actions, strict=True): + selector = gbt.H.path(*infoset) + infoset_action_values = profile.action_values[selector] + for value, action in zip( + values_for_infoset, game.get_actions(selector), strict=True + ): value = gbt.Rational(value) if rational_flag else value assert infoset_action_values[action] == value @@ -833,11 +815,12 @@ def test_action_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - for action in infoset.actions: - assert profile.action_regrets[node][action] == max( - profile.action_values[node][a] for a in infoset.actions - ) - profile.action_values[node][action] + selector = gbt.H.path(*infoset) + actions = game.get_actions(selector) + for action in actions: + assert profile.action_regrets[selector][action] == max( + profile.action_values[selector][a] for a in actions + ) - profile.action_values[selector][action] @pytest.mark.parametrize( @@ -855,10 +838,10 @@ def test_infoset_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - assert profile.infoset_regrets[node] == max( - profile.action_values[node][a] for a in infoset.actions - ) - profile.infoset_values[node] + selector = gbt.H.path(*infoset) + assert profile.infoset_regrets[selector] == max( + profile.action_values[selector][a] for a in game.get_actions(selector) + ) - profile.infoset_values[selector] @pytest.mark.parametrize( @@ -894,7 +877,7 @@ def test_agent_max_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) infoset_regrets = profile.infoset_regrets assert profile.agent_max_regret() == max( - infoset_regrets[next(iter(infoset.members))] for infoset in games.all_infosets(game) + infoset_regrets[gbt.H.path(*infoset)] for infoset in games.all_infosets(game) ) @@ -939,34 +922,35 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): for player in game.players: player_node_values = node_values[player] assert isinstance(player_node_values, gbt.NodeValueVector) - assert player_node_values[game.root] == payoffs[player] + assert player_node_values[()] == payoffs[player] for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - infoset_action_values = action_values[node] - infoset_action_regrets = action_regrets[node] + selector = gbt.H.path(*infoset) + actions = game.get_actions(selector) + infoset_action_values = action_values[selector] + infoset_action_regrets = action_regrets[selector] assert isinstance(infoset_action_values, gbt.ActionValueVector) assert isinstance(infoset_action_regrets, gbt.ActionRegretVector) - best_response_value = max(infoset_action_values[a] for a in infoset.actions) - assert infoset_regrets[node] == best_response_value - infoset_values[node] - for action in infoset.actions: + best_response_value = max(infoset_action_values[a] for a in actions) + assert infoset_regrets[selector] == best_response_value - infoset_values[selector] + for action in actions: assert ( infoset_action_regrets[action] == best_response_value - infoset_action_values[action] ) - for node in game.nodes: - if node.is_terminal: + for history in game.get_histories(gbt.H.after()): + if not game.get_actions(gbt.H.path(*history)): continue - if infoset_probs[node] == 0: - assert beliefs[node] is None + if infoset_probs[gbt.H.path(*history)] == 0: + assert beliefs[history] is None else: - assert beliefs[node] is not None + assert beliefs[history] is not None # equal to an equivalent plain dict or same-type vector, but never to a vector of a # different quantity, even where the underlying numbers happen to coincide - expected = {n: realiz_probs[n] for n in game.nodes} + expected = dict(realiz_probs) assert realiz_probs == expected assert realiz_probs == gbt.RealizProbVector(expected) assert realiz_probs != beliefs @@ -1039,8 +1023,11 @@ def test_action_regrets_reference( if action_probs: _set_action_probs(profile, action_probs, rational_flag) for regrets_for_infoset, infoset in zip(action_regrets, games.all_infosets(game), strict=True): - infoset_action_regrets = profile.action_regrets[next(iter(infoset.members))] - for regret, action in zip(regrets_for_infoset, infoset.actions, strict=True): + selector = gbt.H.path(*infoset) + infoset_action_regrets = profile.action_regrets[selector] + for regret, action in zip( + regrets_for_infoset, game.get_actions(selector), strict=True + ): regret = gbt.Rational(regret) if rational_flag else regret assert abs(infoset_action_regrets[action] - regret) <= tol @@ -1062,16 +1049,17 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) realiz_probs = profile.realiz_probs node_values = profile.node_values - for node in game.nodes: - if node.is_terminal or bool(node.event): + for history in game.get_histories(gbt.H.after()): + player = game.get_player(gbt.H.path(*history)) + if player is None or player == "Chance": continue expected_val = 0 - node_prob = realiz_probs[node] - player_node_values = node_values[node.player] - for child in node.children: - prob = realiz_probs[child] / node_prob - expected_val += prob * player_node_values[child] - assert player_node_values[node] == expected_val + node_prob = realiz_probs[history] + player_node_values = node_values[player] + for child_history in games.children_histories(game, history): + prob = realiz_probs[child_history] / node_prob + expected_val += prob * player_node_values[child_history] + assert player_node_values[history] == expected_val @pytest.mark.parametrize( @@ -1090,7 +1078,7 @@ def test_node_value_consistency(game: gbt.Game, rational_flag: bool): node_values = profile.node_values payoffs = profile.payoffs for player in game.players: - assert node_values[player][game.root] == payoffs[player] + assert node_values[player][()] == payoffs[player] @pytest.mark.parametrize( @@ -1387,15 +1375,11 @@ def test_node_belief_reference( value: str | float, rational_flag: bool, ): - # nodes have no labels, so each belief node is reached by walking the - # action-label path from the root (an empty path is the root itself) + # a node's History is the tuple of action labels from the root (empty for the root) profile = game.mixed_behavior_profile(rational=rational_flag) _set_action_probs(profile, probs, rational_flag) - node = game.root - for action_label in path: - node = node.children[action_label] value = gbt.Rational(value) if rational_flag else value - assert abs(profile.beliefs[node] - value) <= tol + assert abs(profile.beliefs[tuple(path)] - value) <= tol @pytest.mark.parametrize( @@ -1411,7 +1395,9 @@ def test_infoset_value_error_with_chance_player_infoset(game: gbt.Game, rational """ chance_node = game.get_events()[0] with pytest.raises(KeyError): - game.mixed_behavior_profile(rational=rational_flag).infoset_values[chance_node] + game.mixed_behavior_profile(rational=rational_flag).infoset_values[ + gbt.H.path(*chance_node) + ] @pytest.mark.parametrize( @@ -1427,16 +1413,19 @@ def test_action_value_error_with_chance_player_action(game: gbt.Game, rational_f """ chance_node = game.get_events()[0] with pytest.raises(KeyError): - game.mixed_behavior_profile(rational=rational_flag).action_values[chance_node] + game.mixed_behavior_profile(rational=rational_flag).action_values[ + gbt.H.path(*chance_node) + ] -def _all_node_actions(game: gbt.Game) -> list[tuple[gbt.Node, str]]: - """All (node, action label) pairs across every personal player's information sets.""" +def _all_node_actions(game: gbt.Game) -> list[tuple[gbt.Selector, str]]: + """All (selector, action label) pairs across every personal player's information sets.""" return [ - (node, action) + (selector, action) for player in game.players - for node in game.get_infosets(player) - for action in node.actions + for history in game.get_infosets(player) + for selector in [gbt.H.path(*history)] + for action in game.get_actions(selector) ] @@ -1501,7 +1490,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1509,7 +1498,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1517,7 +1506,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1525,7 +1514,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ###################################################################################### # realiz_prob (at nodes) @@ -1535,7 +1524,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1543,7 +1532,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1551,7 +1540,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1559,7 +1548,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ###################################################################################### # infoset_prob @@ -1568,7 +1557,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1576,7 +1565,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1584,7 +1573,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1592,7 +1581,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ###################################################################################### @@ -1602,7 +1591,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1610,7 +1599,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1618,7 +1607,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1626,7 +1615,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ###################################################################################### @@ -1705,7 +1694,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1713,7 +1702,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ( games.create_stripped_down_poker_efg(), @@ -1721,7 +1710,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ( games.create_stripped_down_poker_efg(), @@ -1729,7 +1718,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ###################################################################################### # agent_liap_value (of profile, hence [1] for objects_to_test, @@ -1920,10 +1909,10 @@ def test_specific_profile(game: gbt.Game, rational_flag: bool, data: list): profile = game.mixed_behavior_profile(rational=rational_flag, data=data) flattened = iter([k for i in data for j in i for k in j]) for infoset in games.all_infosets(game): - node = next(iter(infoset.members)) - for action in infoset.actions: + selector = gbt.H.path(*infoset) + for action in game.get_actions(selector): prob = next(flattened) - assert profile[node][action] == (gbt.Rational(prob) if rational_flag else prob) + assert profile[selector][action] == (gbt.Rational(prob) if rational_flag else prob) @pytest.mark.parametrize( @@ -2009,29 +1998,28 @@ def test_undefined_action_value(): """Test that undefined action values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - infoset = games.player_infosets(game, p3)[0] - node = next(iter(infoset.members)) - action = next(iter(infoset.actions)) + selector = gbt.H.path(*games.player_infosets(game, p3)[0]) + action = game.get_actions(selector)[0] for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.action_values[node][action] is None + assert profile.action_values[selector][action] is None def test_undefined_belief(): """Test that undefined beliefs return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - node = next(iter(games.player_infosets(game, p3)[0].members)) + history = games.player_infosets(game, p3)[0] for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.beliefs[node] is None + assert profile.beliefs[history] is None def test_undefined_infoset_value(): """Test that undefined infoset values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - node = next(iter(games.player_infosets(game, p3)[0].members)) + selector = gbt.H.path(*games.player_infosets(game, p3)[0]) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.infoset_values[node] is None + assert profile.infoset_values[selector] is None diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index cfb210d9e3..a8e8323b69 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -5,39 +5,33 @@ from . import games -def _find_infoset(game, label): - """Find the Infoset with the given label, searching across all players.""" - for player in game.players: - for node in game.get_infosets(player): - if node.infoset.label == label: - return node.infoset - raise KeyError(label) +def _find_history(game, label): + """The History of a member node of the infoset with the given label.""" + return games._INFOSET_LABEL_HISTORIES[(game.title, label)] -def _branching_game(): +def _find_selector(game, label): + """A `Selector` resolving to the infoset with the given label.""" + return gbt.H.path(*_find_history(game, label)) + + +def _branching_game() -> gbt.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.append_move(gbt.H.path(), "P1", ["L", "R"]) + game.append_move(gbt.H.path("L"), "P2", ["A", "B"]) + game.append_move(gbt.H.path("R"), "P2", ["A", "B"]) + return game + + +def test_getitem_by_selector(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") - support = profile[infoset] + support = profile[_find_selector(game, "Infoset 1:1")] assert set(support) == {"U1", "D1"} - assert support.infoset == infoset + assert support.history == _find_history(game, "Infoset 1:1") assert "U1" in support assert "not-a-label" not in support @@ -47,8 +41,7 @@ def test_getitem_by_player_label(): profile = game.behavior_support_profile() support = profile["Player 1"] assert support.player == "Player 1" - infoset = _find_infoset(game, "Infoset 1:1") - assert set(support[infoset]) == {"U1", "D1"} + assert set(support[_find_selector(game, "Infoset 1:1")]) == {"U1", "D1"} def test_getitem_unknown_player(): @@ -65,25 +58,30 @@ def test_getitem_rejects_other_types(): 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") +def test_getitem_setitem_reject_history(): + """Profile indexing is `Selector`-only: a bare `History` tuple is no longer + accepted, following the same pattern as `Game.get_minimal_subgame`. + """ + game = _branching_game() profile = game.behavior_support_profile() - with pytest.raises(gbt.MismatchError): - profile[_find_infoset(other, "Infoset 1:1")] + with pytest.raises(TypeError): + profile[("L",)] + with pytest.raises(TypeError): + profile[("L",)] = ["A"] def test_predicate_construction(): game = games.read_from_file("mixed_behavior_game.efg") - profile = game.behavior_support_profile(lambda node, a: a != "D1") - infoset = _find_infoset(game, "Infoset 1:1") - assert set(profile[infoset]) == {"U1"} + profile = game.behavior_support_profile(lambda history, a: a != "D1") + assert set(profile[_find_selector(game, "Infoset 1:1")]) == {"U1"} def test_predicate_construction_error(): + """A predicate that excludes every action at some information set (here, the + root's) triggers "attempted to remove the last action".""" game = games.read_from_file("mixed_behavior_game.efg") with pytest.raises(ValueError): - game.behavior_support_profile(lambda node, a: node.infoset.label != "Infoset 1:1") + game.behavior_support_profile(lambda history, a: len(history) > 0) def test_iter_yields_one_support_per_player(): @@ -106,96 +104,71 @@ def test_behaviorsupport_iter(): def test_setitem_replaces_support(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") - profile[infoset] = ["U1"] - assert set(profile[infoset]) == {"U1"} + selector = _find_selector(game, "Infoset 1:1") + profile[selector] = ["U1"] + assert set(profile[selector]) == {"U1"} def test_setitem_unknown_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") with pytest.raises(ValueError): - profile[infoset] = ["not-a-label"] + profile[_find_selector(game, "Infoset 1:1")] = ["not-a-label"] def test_setitem_empty(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") with pytest.raises(ValueError): - profile[infoset] = [] + profile[_find_selector(game, "Infoset 1:1")] = [] -def test_setitem_rejects_non_infoset(): +def test_setitem_rejects_non_selector(): 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[_find_infoset(other, "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 = _find_infoset(game, "Infoset 1:1") - copy[infoset] = ["U1"] - assert set(copy[infoset]) == {"U1"} - assert set(original[infoset]) == {"U1", "D1"} + selector = _find_selector(game, "Infoset 1:1") + copy[selector] = ["U1"] + assert set(copy[selector]) == {"U1"} + assert set(original[selector]) == {"U1", "D1"} def test_actionsupport_is_snapshot(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") - snapshot = profile[infoset] - profile[infoset] = ["U1"] + selector = _find_selector(game, "Infoset 1:1") + snapshot = profile[selector] + profile[selector] = ["U1"] assert set(snapshot) == {"U1", "D1"} -def test_getitem_setitem_accept_node_infoset(): - game, root_infoset, left_infoset, right_infoset = _branching_game() +def test_getitem_setitem_use_selector(): + game = _branching_game() profile = game.behavior_support_profile() - # game.root.infoset is a live, node-anchored Infoset view -- 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"} + root_selector = gbt.H.path() + assert set(profile[root_selector]) == {"L", "R"} + profile[root_selector] = ["R"] + assert set(profile[root_selector]) == {"R"} -def test_is_reachable(): - game, root_infoset, left_infoset, right_infoset = _branching_game() +def test_is_infoset_reachable(): + game = _branching_game() profile = game.behavior_support_profile() - assert profile.is_reachable(left_infoset) - assert profile.is_reachable(right_infoset) + left_selector = gbt.H.path("L") + right_selector = gbt.H.path("R") + assert profile.is_infoset_reachable(left_selector) + assert profile.is_infoset_reachable(right_selector) copy = profile.copy() - copy[root_infoset] = ["R"] - assert not copy.is_reachable(left_infoset) - assert copy.is_reachable(right_infoset) + copy[gbt.H.path()] = ["R"] + assert not copy.is_infoset_reachable(left_selector) + assert copy.is_infoset_reachable(right_selector) # the original, un-mutated profile is unaffected - assert profile.is_reachable(left_infoset) - - -def test_is_reachable_by_label(): - """`is_reachable` resolves a string as a node's own label, not an infoset's label.""" - game, root_infoset, left_infoset, right_infoset = _branching_game() - left = next(iter(left_infoset.members)) - left.label = "left" - profile = game.behavior_support_profile() - assert profile.is_reachable(left.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) + assert profile.is_infoset_reachable(left_selector) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 961f523067..44a36ce747 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -63,9 +63,9 @@ def test_catalog_games_filter_n_actions(all_games): if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) n_game_actions = sum( - len(node.infoset.actions) + len(g.get_actions(gbt.H.path(*history))) for player in g.players - for node in g.get_infosets(player) + for history in g.get_infosets(player) ) assert n_game_actions == 2 @@ -138,7 +138,7 @@ def test_catalog_games_filter_n_nodes(all_games): assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.nodes) == 5 + assert len(g.get_histories(gbt.H.after())) == 5 def test_catalog_games_filter_n_outcomes(all_games): @@ -148,7 +148,7 @@ def test_catalog_games_filter_n_outcomes(all_games): assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.outcomes) == 3 + assert len(g.get_outcomes()) == 3 def test_catalog_games_filter_n_players(all_games): diff --git a/tests/test_extensive.py b/tests/test_extensive.py index dbe8b1db6f..352f6cedc7 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -97,6 +97,31 @@ def test_is_perfect_recall(game_input, expected_result: bool): assert game.is_perfect_recall == expected_result +@pytest.mark.parametrize("game_input,expected_result", [ + (gbt.catalog.load("journals/geb/wichardt2008"), {"Player 1": False, "Player 2": True}), + ("noPR-information-no-deflate.efg", {"Player 1": True, "Player 2": False}), + ("noPR-action-AM.efg", {"Player 1": False, "Player 2": True}), + ("stripped_down_poker.efg", {"Alice": True, "Bob": True}), + ("gilboa_two_am_agents.efg", {"Player 1": False, "Player 2": True}), + ("2x2.agg", {"1": True, "2": True}), +]) +def test_has_perfect_recall(game_input, expected_result: dict): + """ + Verify the HasPerfectRecall implementation, for individual players, against games + with and without perfect recall, and in each representation. + """ + game = (games.read_from_file(game_input) if isinstance(game_input, str) else game_input) + assert set(game.players) == set(expected_result) + for player, expected in expected_result.items(): + assert game.has_perfect_recall(player) == expected + + +def test_has_perfect_recall_trivial_game(): + game = gbt.Game.new_tree(players=["Alice", "Bob"]) + assert game.has_perfect_recall("Alice") + assert game.has_perfect_recall("Bob") + + def test_getting_payoff_by_label_string(): game = games.read_from_file("sample_extensive_game.efg") s1 = game.get_strategies("Player 1") @@ -507,9 +532,11 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): for strategy, expected in zip(game.get_strategies(player), expected_maps, strict=True): behavior = game.get_behavior(player, strategy) assert tuple( - "*" if (action := behavior.get(infoset)) is None - else str(infoset.actions.index(action) + 1) - for infoset in games.player_infosets(game, player) + "*" if ( + action := behavior.get(gbt.H.path(*history)) + ) is None + else str(game.get_actions(gbt.H.path(*history)).index(action) + 1) + for history in games.player_infosets(game, player) ) == expected diff --git a/tests/test_file.py b/tests/test_file.py index fe4b33bb53..e6da363d12 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -20,24 +20,24 @@ def _parse_nfg(text: str) -> gbt.Game: def test_read_efg_empty_outcome_labels_are_normalized(): g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert [o.label for o in g.outcomes] == ["_1", "_2"] + assert g.get_outcomes() == ["_1", "_2"] def test_read_efg_repeated_outcome_id_consistent(): g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 1 "" { 1, -1 }\n') - assert len(g.outcomes) == 1 + assert len(g.get_outcomes()) == 1 def test_read_efg_empty_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "" "" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert list(g.root.infoset.actions) == ["_1", "_2"] + assert g.get_actions(gbt.H.path()) == ["_1", "_2"] def test_read_efg_duplicate_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "l" "l" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert list(g.root.infoset.actions) == ["l_1", "l_2"] + assert g.get_actions(gbt.H.path()) == ["l_1", "l_2"] def test_read_efg_repeated_infoset_duplicate_labels_consistent(): @@ -53,7 +53,7 @@ def test_read_efg_repeated_infoset_duplicate_labels_consistent(): 't "" 2 "" { 2, -2 }\n' 't "" 3 "" { 3, -3 }\n' ) - assert list(g.root.infoset.actions) == ["l_1", "l_2"] + assert g.get_actions(gbt.H.path()) == ["l_1", "l_2"] _NFG_PAYOFF_BODY = '\n{\n{ "" 1, 1 }\n{ "" 0, 0 }\n{ "" 0, 0 }\n{ "" 1, 1 }\n}\n1 2 3 4\n' diff --git a/tests/test_game.py b/tests/test_game.py index d3a9cfb9be..6ad07338da 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -97,216 +97,8 @@ def test_from_dict(): assert pl2 == "b" -def test_game_get_outcome(): - game = gbt.Game.new_table([2, 2]) - game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "top left") - assert game.get_outcome({"1": "1", "2": "1"}) == next(iter(game.outcomes)) - - -def test_game_get_outcome_by_relabeled_strategies(): - game = gbt.Game.new_table([2, 2]) - pl1, pl2 = game.players - game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) - game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) - game.make_outcome({pl1: "defect", pl2: "cooperate"}, {"1": 0, "2": 0}, "corner") - assert game.get_outcome({pl1: "defect", pl2: "cooperate"}) == \ - next(iter(game.outcomes)) - - -def test_game_get_outcome_incomplete_contingency_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(ValueError): - _ = game.get_outcome({"1": "1"}) - - -def test_game_get_outcome_unknown_player_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(KeyError): - _ = game.get_outcome({"1": "1", "2": "1", "3": "1"}) - - -def test_game_get_outcome_non_mapping_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(TypeError): - _ = game.get_outcome(42) - - -def test_game_get_outcome_non_str_value_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(TypeError): - _ = game.get_outcome({"1": 1.23, "2": "1"}) - - -def test_game_get_outcome_unknown_strategy_label_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(KeyError): - _ = game.get_outcome({"1": "1", "2": "99"}) - - -def test_game_get_outcome_unmatched_label_after_relabel_raises(): - game = gbt.Game.new_table([2, 2]) - pl1, pl2 = game.players - game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) - game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) - with pytest.raises(KeyError): - _ = game.get_outcome({pl1: "defect", pl2: "defect"}) - - -def test_game_get_outcome_tree_raises(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.get_outcome({"Alice": "a"}) - - -def test_game_get_payoffs(): - game = gbt.Game.new_table([2, 2]) - game.make_outcome({"1": "1", "2": "1"}, {"1": 3, "2": -3}, "top left") - payoffs = game.get_payoffs({"1": "1", "2": "1"}) - assert payoffs["1"] == 3 - assert payoffs["2"] == -3 - - -def test_game_get_payoffs_tree(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) - infoset = game.root.infoset - strategy = next( - s for s in game.get_strategies("Alice") - if game.get_behavior("Alice", s).get(infoset) == "a" - ) - game.make_outcome(game.root.children["a"], {"Alice": 1}, "a-outcome") - payoffs = game.get_payoffs({"Alice": strategy}) - assert payoffs["Alice"] == 1 - - -def test_mixed_strategy_profile_game_structure_changed_no_tree(): - game = gbt.Game.from_arrays([[2, 2], [0, 0]], [[0, 0], [1, 1]]) - profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] - player = next(iter(game.players)) - distribution = {s: 0 for s in game.get_strategies(player)} - next(iter(game.outcomes))[player] = 3 - for profile in profiles: - with pytest.raises(gbt.GameStructureChangedError): - profile.copy() - with pytest.raises(gbt.GameStructureChangedError): - profile.liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.max_regret() - with pytest.raises(gbt.GameStructureChangedError): - profile.normalize() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.payoffs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.player_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_values - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_strategy(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(player) - - -def test_mixed_strategy_profile_game_structure_changed_tree(): - game = games.read_from_file("basic_extensive_game.efg") - profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] - player = next(iter(game.players)) - game.set_move_actions(game.root, ["D1"], drop=True) - distribution = {s: 0 for s in game.get_strategies(player)} - for profile in profiles: - with pytest.raises(gbt.GameStructureChangedError): - profile.as_behavior() - with pytest.raises(gbt.GameStructureChangedError): - profile.copy() - with pytest.raises(gbt.GameStructureChangedError): - profile.liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.max_regret() - with pytest.raises(gbt.GameStructureChangedError): - profile.normalize() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.payoffs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.player_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_values - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_strategy(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(player) - - -def test_mixed_behavior_profile_game_structure_changed(): - game = games.read_from_file("basic_extensive_game.efg") - profiles = [game.mixed_behavior_profile(rational=b) for b in [False, True]] - game.set_move_actions(game.root, ["D1"], drop=True) - infoset = game.root - for profile in profiles: - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.action_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.action_values - with pytest.raises(gbt.GameStructureChangedError): - profile.as_strategy() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.beliefs - with pytest.raises(gbt.GameStructureChangedError): - profile.copy() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.infoset_probs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.infoset_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.infoset_values - with pytest.raises(gbt.GameStructureChangedError): - profile.is_defined_at(infoset) - with pytest.raises(gbt.GameStructureChangedError): - profile.agent_liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.agent_max_regret() - with pytest.raises(gbt.GameStructureChangedError): - profile.max_regret() - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.node_values - with pytest.raises(gbt.GameStructureChangedError): - profile.normalize() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.payoffs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.realiz_probs - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(game.root, {}) - with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_action(game.root, {}) - with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(game.root) - - COLLECTION_GETTERS = [ pytest.param(lambda g: g.players, id="GamePlayers"), - pytest.param(lambda g: g.outcomes, id="GameOutcomes"), ] diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 7795dd7b84..6bd7cc1a03 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -9,14 +9,11 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None: - """Generic function to exercise resolving objects as themselves or via existing labels.""" + """Generic function to exercise resolving objects via their existing labels.""" for label, objects in itertools.groupby( - sorted(collection, key=lambda x: x.label), lambda x: x.label + sorted(collection, key=lambda x: x._label), lambda x: x._label ): objects = list(objects) - # Objects resolve to themselves - for obj in objects: - assert obj == resolver(obj, "test") # Ambiguous labels raise ValueError if len(objects) > 1: with pytest.raises(ValueError): @@ -32,7 +29,7 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None ] ) def test_resolve_node(game: gbt.Game) -> None: - _test_valid_resolutions(game.nodes, + _test_valid_resolutions(games.all_nodes(game), lambda label, fn: game._resolve_node(label, fn)) @@ -49,6 +46,14 @@ def test_resolve_node_invalid(game: gbt.Game, node: str, exception: BaseExceptio game._resolve_node(node, "test_resolve_node_invalid") +def test_resolve_node_rejects_node(): + """A bare `Node` -- even one belonging to this same game -- is not accepted; + `_resolve_node` only resolves a `Selector`, `History` tuple, or label `str`.""" + game = games.read_from_file("sample_extensive_game.efg") + with pytest.raises(TypeError): + game._resolve_node(games.node_at_history(game, ()), "test_resolve_node_rejects_node") + + @pytest.mark.parametrize( "game", [ @@ -56,16 +61,18 @@ def test_resolve_node_invalid(game: gbt.Game, node: str, exception: BaseExceptio ] ) def test_resolve_infoset(game: gbt.Game) -> None: - """`_resolve_infoset` resolves a Node to the Infoset it belongs to, or a node's - label to the same; any member node of an infoset resolves to an equal Infoset.""" + """`_resolve_infoset` resolves a Selector to the node it identifies, or a + node's label to that node, validating that it currently belongs to a + personal player's information set; this holds for every member node, not + just the representative `Game.get_infosets` returns.""" for player in game.players: - for node in game.get_infosets(player): - resolved = game._resolve_infoset(node, "test") - assert resolved == node.infoset - if node.label: - assert game._resolve_infoset(node.label, "test") == node.infoset - for member in node.infoset.members: - assert game._resolve_infoset(member, "test") == node.infoset + for history in game.get_infosets(player): + for member in game.get_members(gbt.H.path(*history)): + resolved = game._resolve_infoset(gbt.H.path(*member), "test") + assert games._node_history(resolved) == member + if resolved._label: + resolved_by_label = game._resolve_infoset(resolved._label, "test") + assert games._node_history(resolved_by_label) == member @pytest.mark.parametrize( diff --git a/tests/test_hsel.py b/tests/test_hsel.py new file mode 100644 index 0000000000..c8e82975e9 --- /dev/null +++ b/tests/test_hsel.py @@ -0,0 +1,118 @@ +import pytest + +import pygambit as gbt + + +def test_history_view_members_on_shared_infoset(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.plays, "B", ["x", "y"]) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return frozenset(h.members) + + groups = game._get_groups(gbt.H.path(...).by(key)) + assert captured == { + ("U",): [("U",), ("D",)], + ("D",): [("U",), ("D",)], + } + assert list(groups.values()) == [[("U",), ("D",)]] + + +def test_history_view_members_singleton_infoset(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.path("U"), "B", ["x", "y"]) + game.append_move(gbt.H.path("D"), "B", ["x", "y"]) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return None + + game._get_groups(gbt.H.path(...).by(key)) + assert captured == {("U",): [("U",)], ("D",): [("D",)]} + + +def test_history_view_members_on_event(): + game = gbt.Game.new_tree(players=["A"]) + game.append_event(gbt.H.path(), {"L": 0.5, "R": 0.5}) + game.append_event(gbt.H.plays, {"p": 0.5, "q": 0.5}) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return None + + game._get_groups(gbt.H.path(...).by(key)) + assert captured == {("L",): [("L",), ("R",)], ("R",): [("L",), ("R",)]} + + +def test_history_view_members_raises_on_terminal(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + def key(h): + with pytest.raises(AttributeError): + _ = h.members + return None + + game._get_groups(gbt.H.plays.by(key)) + + +def test_get_histories_root(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + assert game.get_histories(gbt.H.path()) == [()] + + +def test_get_histories_multiple(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.plays, "B", ["x", "y"]) + + assert game.get_histories(gbt.H.path(...)) == [("U",), ("D",)] + assert game.get_histories(gbt.H.plays) == [ + ("U", "x"), ("U", "y"), ("D", "x"), ("D", "y"), + ] + + +def test_get_histories_plays_from_non_root(): + """`.plays` chained after a path prefix is the terminal frontier reachable + from that point, not from the whole game.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig2") + + assert set(game.get_histories(gbt.H.path("L").plays)) == { + ("L", "R"), ("L", "L", "r"), ("L", "L", "l"), + } + + +def test_get_histories_empty(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + assert game.get_histories(gbt.H.after("nonexistent")) == [] + + +def test_get_histories_after_strategic_game_raises(): + """`H.after()`, used bare, enumerates every node -- the replacement for the + removed `Game.nodes` -- so it inherits the same tree-only restriction.""" + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + game.get_histories(gbt.H.after()) + + +def test_get_histories_requires_selector(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + with pytest.raises(TypeError): + game.get_histories(()) + with pytest.raises(TypeError): + game.get_histories("U") diff --git a/tests/test_infosets.py b/tests/test_infosets.py deleted file mode 100644 index 3ad6b3ba1c..0000000000 --- a/tests/test_infosets.py +++ /dev/null @@ -1,503 +0,0 @@ -import dataclasses -import functools -import itertools -import typing - -import pytest - -import pygambit as gbt - -from . import games - - -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_infoset_set_label(label): - game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_infoset_label_invalid_raises_valueerror(label): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.root.infoset.label = label - - -@pytest.mark.parametrize("label", games.UNICODE_LABELS) -def test_infoset_label_unicode_accepted(label): - """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" - game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label - - -def test_infoset_label_duplicate_within_player_raises_valueerror(): - game = games.read_from_file("subgames.efg") - player = next(p for p in game.players if len(game.get_infosets(p)) >= 2) - first, second = (n.infoset for n in itertools.islice(game.get_infosets(player), 2)) - first.label = "shared" - with pytest.raises(ValueError): - second.label = "shared" - - -def test_infoset_player_retrieval(): - game = games.read_from_file("basic_extensive_game.efg") - p1, *_ = game.players - assert p1 == game.root.infoset.player - - -def test_infoset_node_precedes(): - game = games.read_from_file("basic_extensive_game.efg") - assert not game.root.infoset.precedes(game.root) - assert game.root.children["U1"].infoset.precedes(game.root.children["U1"]) - - -def test_make_infoset_change_player_keeps_label(): - """Re-forming an information set under a different player retains an - explicitly specified label and its membership.""" - game = games.read_from_file("basic_extensive_game.efg") - _, p2, *_ = game.players - members = list(game.root.infoset.members) - game.make_infoset(members, p2, "moved") - assert game.root.infoset.player == p2 - assert game.root.infoset.label == "moved" - assert list(game.root.infoset.members) == members - - -def test_make_infoset_mismatch_raises(): - """Nodes must belong to this game.""" - game1 = games.read_from_file("basic_extensive_game.efg") - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.make_infoset(game2.root, "Player 1") - - -def test_make_infoset_terminal_node_raises(): - """All nodes must be decision nodes.""" - game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] - with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([terminal], game.root.player) - - -def test_make_infoset_converts_chance_node(): - """A chance node becomes a personal decision node, discarding its probabilities.""" - game = games.read_from_file("stripped_down_poker.efg") - chance_node = game.root # the deal is a chance move - personal = next(n for n in game.nodes if not n.is_terminal and n.infoset) - game.make_infoset([chance_node], personal.infoset.player) - assert not chance_node.event - assert chance_node.infoset - assert chance_node.infoset.player == personal.infoset.player - - -@pytest.mark.parametrize("node_actions", [["c", "d"], ["b", "a"]]) -def test_make_infoset_requires_matching_action_labels(node_actions): - """Nodes must have the same actions, with the same labels in the same order; - a matching count is not sufficient.""" - game = gbt.Game.new_tree(players=["1"]) - game.append_move(game.root, "1", ["a", "b"]) - game.append_move(game.root.children["a"], "1", node_actions) - with pytest.raises(ValueError): - game.make_infoset([game.root, game.root.children["a"]], "1") - - -def test_make_infoset_empty_nodes_raises(): - """`nodes` must be nonempty.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.make_infoset([], game.root.player) - - -def test_make_infoset_repeated_node_raises(): - """Each node may be referenced only once.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.make_infoset([game.root, game.root], game.root.player) - - -def test_make_infoset_strategic_game_raises(): - """`make_infoset` is only defined for games with a tree representation.""" - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([], "1") - - -def test_set_move_actions_add_preserves_existing_action_order(): - """New actions may be declared at any position; the existing actions' relative - order is preserved.""" - game = games.read_from_file("basic_extensive_game.efg") - labels = list(game.root.actions) - game.set_move_actions(game.root, labels + ["end"]) - assert list(game.root.actions)[:-1] == labels - game.set_move_actions(game.root, ["front"] + labels + ["end"]) - assert list(game.root.actions)[1:-1] == labels - - -@pytest.mark.parametrize( - "inprobs,outprobs", - [ - (["1/4", "3/4"], [gbt.Rational("1/4"), gbt.Rational("3/4")]), - ([0.75, 0.25], [0.75, 0.25]), - ({"King": 1}, [1, 0]), - ], -) -def test_make_event_sets_probabilities(inprobs, outprobs): - """Probabilities may be given positionally, or as a mapping in which omitted - actions are assigned zero. - """ - game = games.read_from_file("stripped_down_poker.efg") - game.make_event([game.root], inprobs, "Deal") - probs = game.root.action_probs - for action, prob in zip(game.root.actions, outprobs, strict=True): - assert probs[action] == prob - - -def test_make_event_pools_nodes_from_different_infosets(): - """Nodes in distinct information sets are formed into a single event.""" - game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(nodes, ["1/4", "3/4"], "Coin") - assert nodes[0].event == nodes[1].event - assert nodes[0].event - assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] - assert not game.get_infosets("Alice") - - -@pytest.mark.parametrize("probs", [["1/2", "1/2"], {"Call": 1}]) -def test_make_event_requires_matching_action_labels(probs): - """Nodes must have the same actions, with the same labels in the same order. - - The mapping case was previously reported as an unknown action label. - """ - game = games.read_from_file("stripped_down_poker.efg") - alice_node = game.root.children["King"] # actions Bet, Fold - bob_node = alice_node.children["Bet"] # actions Call, Fold - with pytest.raises(ValueError): - game.make_event([alice_node, bob_node], probs) - - -def test_make_event_converts_personal_node(): - """A personal decision node becomes a chance node carrying the probabilities given.""" - game = games.read_from_file("stripped_down_poker.efg") - node = next( - n for n in game.get_infosets("Alice") if n.infoset.label == "Alice has King" - ) - game.make_event([node], ["1/4", "3/4"]) - assert node.event - assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] - - -def test_make_event_terminal_node_raises(): - game = games.read_from_file("stripped_down_poker.efg") - terminal = game.root.children["King"].children["Fold"] - with pytest.raises(gbt.UndefinedOperationError): - game.make_event([terminal], ["1/2", "1/2"]) - - -def test_make_event_repeated_node_raises(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - game.make_event([game.root, game.root], ["1/2", "1/2"]) - - -def test_make_event_mismatch_raises(): - game = games.read_from_file("stripped_down_poker.efg") - other = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(gbt.MismatchError): - game.make_event([other.root], ["1/2", "1/2"]) - - -def test_make_event_strategic_game_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - game.make_event([], [1]) - - -def test_make_event_empty_nodes_raises(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - game.make_event([], ["1/2", "1/2"]) - - -def test_make_event_label_held_by_rump_raises(): - """A label may be reused only if all members of the event holding it are absorbed.""" - game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(nodes, ["1/2", "1/2"], "Coin") - before = game.to_efg() - with pytest.raises(ValueError): - game.make_event([nodes[0]], ["1/2", "1/2"], "Coin") - assert game.to_efg() == before - - -def test_make_event_label_reused_when_fully_absorbed(): - """A label held by an existing event may be reused once all of that - event's members are absorbed into the new one; the old event is not left behind. - """ - game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(nodes, ["1/2", "1/2"], "Coin") - game.make_event(nodes, ["1/4", "3/4"], "Coin") - assert nodes[0].event == nodes[1].event - assert nodes[0].event.label == "Coin" - assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] - assert [ - n.event.label for n in game.get_events() - ].count("Coin") == 1 - - -@pytest.mark.parametrize("probs", [["3/4", "-1/2"], [0.75, 0.40], ["foo", "bar"]]) -def test_make_event_invalid_probs_raises(probs): - """Values must be numbers, non-negative, and sum to exactly one.""" - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - game.make_event([game.root], probs) - - -@pytest.mark.parametrize( - "probs,error", - [(["1/2"], IndexError), (["1/3", "1/3", "1/3"], IndexError), ({"Jack": 1}, KeyError)], -) -def test_make_event_malformed_probs_raises(probs, error): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(error): - game.make_event([game.root], probs) - - -@dataclasses.dataclass -class AbsentMindednessTestCase: - """TestCase for testing is_absent_minded.""" - factory: typing.Callable[[], gbt.Game] - expected_am_paths: list[list[str]] - - -ABSENT_MINDEDNESS_CASES = [ - # Games without absent-mindedness - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(gbt.catalog.load, "journals/ijgt/selten1975/fig2"), - expected_am_paths=[] - ), - id="short_centipede_perfect_info" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "stripped_down_poker.efg"), - expected_am_paths=[] - ), - id="poker_stripped" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "basic_extensive_game.efg"), - expected_am_paths=[] - ), - id="basic_extensive" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "gilboa_two_am_agents.efg"), - expected_am_paths=[] - ), - id="gilboa_forgetting_info" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - expected_am_paths=[] - ), - id="wichardt_forgetting_action" - ), - # Games with absent-mindedness - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "noPR-AM-driver-two-players.efg"), - expected_am_paths=[[]] - ), - id="AM_driver_two_players" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "noPR-action-AM.efg"), - expected_am_paths=[[]] - ), - id="AM_forgetting_action" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "noPR-action-AM-two-hops.efg"), - expected_am_paths=[["2", "1", "1", "1", "1"], ["1", "1", "1"]] - ), - id="AM_infoset_takes_two_hops" - ), -] - - -def _get_node_by_path(game, path: list[str]) -> gbt.Node: - """ - Helper to find a node by following a sequence of action labels. - """ - node = game.root - for action_label in reversed(path): - node = node.children[action_label] - return node - - -@pytest.mark.parametrize("test_case", ABSENT_MINDEDNESS_CASES) -def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): - """ - Test `infoset.is_absent_minded`. - - Verifies that the set of infosets marked as absent-minded matches the - expected set derived from action paths. - """ - game = test_case.factory() - - expected_infosets = { - _get_node_by_path(game, path).infoset - for path in test_case.expected_am_paths - } - actual_infosets = { - n.infoset for p in game.players for n in game.get_infosets(p) - if n.infoset.is_absent_minded - } - - assert actual_infosets == expected_infosets - - -def _bagwell_p2_nodes(game: gbt.Game) -> tuple[gbt.Node, gbt.Node, gbt.Node, gbt.Node]: - """Player 2's four decision nodes in Bagwell (1995). - - Player 2 has two information sets -- one for each signal the chance move - can produce -- each with two members and actions ("S", "C"). Returns - (A, B, C, D) with {A, B} the members of one and {C, D} of the other. - """ - return (game.root.children["S"].children["s"], - game.root.children["C"].children["s"], - game.root.children["S"].children["c"], - game.root.children["C"].children["c"]) - - -def test_make_infoset_cherry_pick_leaves_rumps(): - """Partial consumption leaves the remainders behind, labels retained.""" - game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" - C.infoset.label = "Y" - game.make_infoset([B, C], "Player 2") - assert B.infoset == C.infoset - assert list(A.infoset.members) == [A] - assert list(D.infoset.members) == [D] - assert A.infoset.label == "X" - assert D.infoset.label == "Y" - - -def test_make_infoset_label_held_by_rump_raises(): - """Reusing a label whose infoset is only partly consumed is rejected.""" - game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" - with pytest.raises(ValueError): - game.make_infoset([B, C], "Player 2", "X") # A remains in "X" - - -def test_make_infoset_failure_leaves_game_unchanged(): - """A rejected call must not modify the partition.""" - game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" - C.infoset.label = "Y" - with pytest.raises(ValueError): - game.make_infoset([B, C], "Player 2", "X") - assert A.infoset == B.infoset - assert C.infoset == D.infoset - assert A.infoset.label == "X" - assert C.infoset.label == "Y" - - -def test_make_infoset_idempotent(): - """Repeating a call is a no-op: label reuse permits equality of membership.""" - game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - game.make_infoset([B, C], "Player 2", "Z") - game.make_infoset([B, C], "Player 2", "Z") - assert B.infoset == C.infoset - assert B.infoset.label == "Z" - - -def test_make_infoset_split_leaves_new_infoset_unlabeled(): - """A node split out gets a fresh unlabeled infoset; the rump keeps the label.""" - game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" - game.make_infoset([A], "Player 2") - assert A.infoset.label == "" - assert B.infoset.label == "X" - - -def test_make_infoset_across_different_source_players(): - """Nodes drawn from different players all land under the target player.""" - game = gbt.Game.new_tree(players=["1", "2", "3"]) - game.append_move(game.root, "1", ["a", "b"]) - game.append_move(game.root.children["a"], "2", ["a", "b"]) # player 2 - game.append_move(game.root.children["b"], "3", ["a", "b"]) # player 3 - n2 = game.root.children["a"] - n3 = game.root.children["b"] - assert n2.infoset.player == "2" - assert n3.infoset.player == "3" - game.make_infoset([n2, n3], "1") - assert n2.infoset == n3.infoset - assert n2.infoset.player == "1" - assert n3.infoset.player == "1" - - -def test_infoset_proxy_reresolves_after_split(): - """A node-anchored infoset proxy is lazy: it re-resolves after the node is - placed in a new information set.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - proxy = node.infoset - assert len(proxy.members) == 2 - game.make_infoset(node, node.player) - assert list(proxy.members) == [node] - - -def test_infoset_members_is_a_plain_snapshot_list(): - """`members` returns a plain `list`, not a lazily-resolved view: it supports - integer indexing, and a list obtained before a mutation keeps reflecting the - information set as it was at the time, rather than tracking its owner.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - members = node.infoset.members - assert isinstance(members, list) - assert node in (members[0], members[1]) - game.make_infoset(node, node.player) - assert len(members) == 2 - assert list(node.infoset.members) == [node] - - -def test_reveal_splits_infoset_by_action(): - """Revealing the deal to Bob separates his single infoset into per-card - singletons; the other player's structure is untouched.""" - game = games.create_stripped_down_poker_efg(nonterm_outcomes=True) - n_alice = len(game.get_infosets("Alice")) - assert len(game.get_infosets("Bob")) == 1 - game.reveal(game.root, "Bob") - bob = game.get_infosets("Bob") - assert len(bob) == 2 - assert all(len(list(n.infoset.members)) == 1 for n in bob) - assert len(game.get_infosets("Alice")) == n_alice - - -def test_reveal_absent_minded_infoset_raises(): - """Revealing the move at an absent-minded infoset is rejected (17.0).""" - game = gbt.Game.new_tree(players=["Driver", "2"]) - game.append_move(game.root, "Driver", ["Continue", "Exit"]) - mid = game.root.children["Continue"] - game.append_move(mid, "Driver", ["Continue", "Exit"]) - game.make_infoset([game.root, mid], "Driver") - game.append_move(mid.children["Continue"], "2", ["l", "r"]) - with pytest.raises(gbt.UndefinedOperationError): - game.reveal(game.root, "2") diff --git a/tests/test_nash.py b/tests/test_nash.py index 80030858e4..265affebff 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -23,10 +23,10 @@ def d(*probs) -> tuple: return tuple(probs) -def _action_prob(profile: gbt.MixedBehaviorProfile, node: gbt.Node, label: str): - """The probability profile assigns to the action labeled `label` at `node`'s - information set.""" - return profile[node][label] +def _action_prob(profile: gbt.MixedBehaviorProfile, history: tuple, label: str): + """The probability profile assigns to the action labeled `label` at the + information set identified by `history`.""" + return profile[gbt.H.path(*history)][label] @dataclasses.dataclass @@ -3255,11 +3255,11 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player): - for action in node.actions: + for history in game.get_infosets(player): + for action in game.get_actions(gbt.H.path(*history)): assert abs( - _action_prob(eq, node, action) - - _action_prob(expected, node, action) + _action_prob(eq, history, action) + - _action_prob(expected, history, action) ) <= test_case.prob_tol @@ -3307,10 +3307,10 @@ def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests def are_the_same(game, found, candidate): for p in game.players: - for node in game.get_infosets(p): - for a in node.actions: + for history in game.get_infosets(p): + for a in game.get_actions(gbt.H.path(*history)): if not abs( - _action_prob(found, node, a) - _action_prob(candidate, node, a) + _action_prob(found, history, a) - _action_prob(candidate, history, a) ) <= TOL: return False return True @@ -3474,11 +3474,11 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player): - for action in node.actions: + for history in game.get_infosets(player): + for action in game.get_actions(gbt.H.path(*history)): assert abs( - _action_prob(eq, node, action) - - _action_prob(expected, node, action) + _action_prob(eq, history, action) + - _action_prob(expected, history, action) ) <= test_case.prob_tol @@ -3542,11 +3542,11 @@ def test_nash_agent_w_start_solver(test_case: EquilibriumTestCase, subtests) -> with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player): - for action in node.actions: + for history in game.get_infosets(player): + for action in game.get_actions(gbt.H.path(*history)): assert abs( - _action_prob(eq, node, action) - - _action_prob(expected, node, action) + _action_prob(eq, history, action) + - _action_prob(expected, history, action) ) <= test_case.prob_tol diff --git a/tests/test_node.py b/tests/test_node.py deleted file mode 100644 index f6e98692f4..0000000000 --- a/tests/test_node.py +++ /dev/null @@ -1,1339 +0,0 @@ -import dataclasses -import functools -import itertools -import typing - -import pytest - -import pygambit as gbt - -from . import games - - -def test_get_infoset(): - """Test to ensure that we can retrieve an infoset for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.infoset - assert game.root.children["U1"].infoset - assert not game.root.children["U1"].children["D2"].children["U3"].infoset - - -def test_infoset_equality_is_symmetric(): - """A node-anchored infoset proxy and a separately-constructed Infoset compare - equal from either side.""" - game = games.read_from_file("basic_extensive_game.efg") - proxy = game.root.infoset - infoset = game.get_infosets(game.root.player)[0].infoset - assert proxy == infoset - assert infoset == proxy - - -def test_node_infoset_truthiness(): - """A node-anchored infoset view is truthy iff the node currently has an - infoset, and tracks mutation across the terminal boundary.""" - game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["D2"].children["U3"] - proxy = terminal.infoset - assert not proxy - game.append_move(terminal, "Player 1", ["a", "b"]) - assert proxy - - -def test_get_outcome(): - """Test to ensure that we can retrieve an outcome for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert ( - game.root.children["U1"].children["D2"].children["U3"].outcome - == game.outcomes["Outcome 1"] - ) - assert not game.root.outcome - - -def test_make_outcome_null(): - """Resetting a node's outcome to null leaves the node's outcome view falsy.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["U2"].children["U3"] - game.make_outcome_null(node) - assert not node.outcome - - -def test_node_outcome_subscript_tracks_mutation(): - """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] - proxy = node.outcome - player = "Player 1" - proxy[player] = 7 - assert node.outcome[player] == 7 - - -def test_outcome_equality_is_symmetric(): - """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] - proxy = node.outcome - outcome = game.outcomes["Outcome 1"] - assert proxy == outcome - assert outcome == proxy - - -def test_null_outcome_label_is_none(): - """The blessed nullity idiom: a node with no outcome has `outcome.label is None`.""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.label is None - - -def test_null_outcome_compares_unequal_to_itself(): - """Null outcomes are unequal to everything, including another view of the same - node's outcome; equality must not short-circuit on node identity.""" - game = games.read_from_file("basic_extensive_game.efg") - assert (game.root.outcome == game.root.outcome) is False - - -def test_null_outcome_reads_zero_payoffs(): - """Reading a payoff through an unset node reports zero to every player of the game.""" - game = games.read_from_file("basic_extensive_game.efg") - for player in game.players: - assert game.root.outcome[player] == 0 - - -def test_null_outcome_payoff_write_raises(): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.root.outcome["Player 1"] = 1 - - -def test_null_outcome_label_write_raises(): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.root.outcome.label = "Outcome 4" - - -def test_null_outcome_number_is_none(): - """The null outcome is not a member of the game's outcomes, so it has no number. - -1 would be a valid index and would silently resolve to the last real outcome.""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.number is None - - -def test_get_player(): - """Test to ensure that we can retrieve a player for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.player == "Player 1" - assert not game.root.children["U1"].children["D2"].children["U3"].player - - -def test_node_player_resolves_chance(): - """At a chance node, the player label is the chance player's.""" - game = games.read_from_file("stripped_down_poker.efg") - chance_node = game.root - assert chance_node.event - assert chance_node.player == "Chance" - - -def test_get_game(): - """Test to ensure that we can retrieve the game object from a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game == game.root.game - - -def test_get_parent(): - """Test to ensure that we can retrieve a parent node for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].parent == game.root - assert game.root.parent is None - - -def test_get_prior_action(): - """Test to ensure that we can retrieve the prior action for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].prior_action == gbt.Branch(game.root, "U1") - assert game.root.prior_action is None - - -def test_get_prior_sibling(): - """Test to ensure that we can retrieve a prior sibling of a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["D1"].prior_sibling == game.root.children["U1"] - assert game.root.children["U1"].prior_sibling is None - - -def test_get_next_sibling(): - """Test to ensure that we can retrieve a next sibling of a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].next_sibling == game.root.children["D1"] - assert game.root.children["D1"].next_sibling is None - - -def test_is_terminal(): - """Test to ensure that we can check if a given node is a terminal node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.is_terminal is False - assert game.root.children["U1"].children["U2"].children["U3"].is_terminal is True - - -def test_is_successor_of(): - """Test to ensure that we can check if a given node is a - successor of a supplied node - """ - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].is_successor_of(game.root) - assert not game.root.is_successor_of(game.root.children["U1"]) - with pytest.raises(TypeError): - game.root.is_successor_of(9) - with pytest.raises(TypeError): - game.root.is_successor_of("Test") - with pytest.raises(TypeError): - game.root.is_successor_of("Player 1") - - -def _get_path_of_action_labels(node: gbt.Node) -> list[str]: - """ - Computes the path of action labels from a given node to the root. - Returns a list of strings. - """ - if not isinstance(node, gbt.Node): - raise TypeError(f"Input must be a pygambit.Node, but got {type(node).__name__}") - - path = [] - current_node = node - while current_node.parent: - path.append(current_node.prior_action.label) - current_node = current_node.parent - - return path - - -@dataclasses.dataclass -class SubgameRootsTestCase: - """TestCase for testing subgame root detection.""" - factory: typing.Callable[[], gbt.Game] - expected_paths: list[list[str]] - - -SUBGAME_ROOTS_CASES = [ - # ------------------------------------------------------------------------ - # Empty Game - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase(factory=gbt.Game.new_tree, expected_paths=[[]]), - id="empty_tree" - ), - # ------------------------------------------------------------------------ - # Perfect Information Games - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(gbt.catalog.load, "journals/ijgt/selten1975/fig2"), - expected_paths=[[], ["L"], ["L", "L"]] - ), - id="centipede_3_rounds" - ), - pytest.param( - SubgameRootsTestCase( - factory=lambda: games.Centipede.get_test_data(N=5, m0=2, m1=7)[0], - expected_paths=[[], ["Push"], ["Push", "Push"], ["Push", "Push", "Push"], - ["Push", "Push", "Push", "Push"]] - ), - id="centipede_5_rounds" - ), - # ------------------------------------------------------------------------ - # Imperfect Information (No Absent-Mindedness) - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - expected_paths=[[]] - ), - id="wichardt_no_nontrivial_subgames" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "binary_3_levels_generic_payoffs.efg"), - expected_paths=[[]] - ), - id="binary_3_levels_no_nontrivial_subgames" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial( - games.read_from_file, - "subgame_roots_finder_small_subgames_and_overplapping_infosets.efg"), - expected_paths=[[], ["1"], ["2"], ["1", "2", "2"], ["2", "1", "2"], - ["1", "1", "1", "2", "2"], ["2", "2", "2"]] - ), - id="small_subgames_and_overlapping_infosets_inside_subgames_no_Nature_moves" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial( - games.read_from_file, - "subgame_roots_finder_overplapping_infosets_with_Nature.efg"), - expected_paths=[[], ["1_2"], ["1_2", "1_3", "1_2"], ["1_3", "1_2"]] - ), - id="overlapping_infosets_inside_subgames_and_Nature_move" - ), - # ------------------------------------------------------------------------ - # Absent-Minded Games - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "AM-subgames.efg"), - expected_paths=[[], ["2"], ["1", "1"], ["2", "1"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-two-times" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "noPR-action-AM-two-hops.efg"), - expected_paths=[[], ["2", "1", "1"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-three-times" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "AM-unary-hops.efg"), - expected_paths=[[], ["1", "1"], ["T", "1", "1", "1", "1", "1"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-two-times" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "AM-unary-branches.efg"), - expected_paths=[[], ["1", "1", "1", "T"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-two-times" - ), -] - - -@pytest.mark.parametrize("test_case", SUBGAME_ROOTS_CASES) -def test_subgame_roots(test_case: SubgameRootsTestCase): - """ - Tests that the set of nodes marked as subgame roots matches the expected - set of paths (Action Labels from Root -> Node). - """ - game = test_case.factory() - - actual_roots = [node for node in game.nodes if node.is_subgame_root] - actual_paths = [_get_path_of_action_labels(node) for node in actual_roots] - - assert sorted(actual_paths) == sorted(test_case.expected_paths) - - -# ============================================================================ -# Subgame tree / GameSubgame -# ============================================================================ -@dataclasses.dataclass -class SubgameStructureTestCase: - """Expected subgame structure of a game. - - `roots` lists each subgame root as a node->root action-label path, in the - postorder `game.subgames` is expected to produce (children before parents). - - `parents` maps each subgame-root path to its expected parent path - (or None for the root subgame). - - `children` maps each subgame-root path to the set of its child subgame paths. - - `differences` maps each subgame-root path to the set of - (player_label, infoset_number) keys in that subgame's difference --- - the information sets belonging to the subgame but not to any child subgame. - """ - factory: typing.Callable[[], gbt.Game] - roots: list[list[str]] - parents: dict[tuple[str, ...], tuple[str, ...] | None] - children: dict[tuple[str, ...], set[tuple[str, ...]]] - differences: dict[tuple[str, ...], set[tuple[str, int]]] - - -SUBGAME_STRUCTURE_CASES = [ - # ------------------------------------------------------------------------ - # EF game with the only subgame - # ------------------------------------------------------------------------ - pytest.param( - SubgameStructureTestCase( - factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - roots=[[]], - parents={(): None}, - children={(): set()}, - differences={(): {("Player 1", 0), ("Player 1", 1), ("Player 2", 0)}}, - ), - id="wichardt_no_nontrivial_subgames", - ), - # ------------------------------------------------------------------------ - # Tree with eight subgames - # ------------------------------------------------------------------------ - pytest.param( - SubgameStructureTestCase( - factory=functools.partial(games.read_from_file, "subgame-8-roots.efg"), - roots=[ - ["L", "L", "L", "L", "L"], - ["R", "L", "L", "L", "L"], - ["L", "L", "L", "L"], - ["L", "L"], - ["R", "L"], - ["L"], - ["R"], - [], - ], - parents={ - ("L", "L", "L", "L", "L"): ("L", "L", "L", "L"), - ("R", "L", "L", "L", "L"): ("L", "L", "L", "L"), - ("L", "L", "L", "L"): ("L", "L"), - ("L", "L"): ("L",), - ("R", "L"): ("L",), - ("L",): (), - ("R",): (), - (): None, - }, - children={ - ("L", "L", "L", "L", "L"): set(), - ("R", "L", "L", "L", "L"): set(), - ("L", "L", "L", "L"): {("L", "L", "L", "L", "L"), - ("R", "L", "L", "L", "L")}, - ("L", "L"): {("L", "L", "L", "L")}, - ("R", "L"): set(), - ("L",): {("L", "L"), ("R", "L")}, - ("R",): set(), - (): {("L",), ("R",)}, - }, - differences={ - ("L", "L", "L", "L", "L"): { - ("Player 1", 3), ("Player 2", 2), ("Player 2", 3), - }, - ("R", "L", "L", "L", "L"): {("Player 1", 4), ("Player 1", 5)}, - ("L", "L", "L", "L"): {("Player 2", 1)}, - ("L", "L"): {("Player 1", 1), ("Player 1", 2)}, - ("R", "L"): {("Player 1", 6)}, - ("L",): {("Player 2", 0)}, - ("R",): { - ("Player 1", 7), ("Player 1", 8), ("Player 1", 9), - ("Player 2", 4), ("Player 2", 5), ("Player 2", 6), - }, - (): {("Player 1", 0)}, - }, - ), - id="eight_subgames", - ), -] - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgames_postorder_sequence(test_case: SubgameStructureTestCase): - """`game.subgames` produces the expected postorder sequence of roots.""" - game = test_case.factory() - actual = [_get_path_of_action_labels(sg.root) for sg in game.subgames] - assert actual == test_case.roots - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgame_parent_links(test_case: SubgameStructureTestCase): - """Each subgame's `parent` matches the expected parent path.""" - game = test_case.factory() - for sg in game.subgames: - path = tuple(_get_path_of_action_labels(sg.root)) - parent_path = ( - None if sg.parent is None - else tuple(_get_path_of_action_labels(sg.parent.root)) - ) - assert parent_path == test_case.parents[path] - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgame_children(test_case: SubgameStructureTestCase): - """Each subgame's `children` match the expected set of child paths.""" - game = test_case.factory() - actual = { - tuple(_get_path_of_action_labels(sg.root)): - {tuple(_get_path_of_action_labels(c.root)) for c in sg.children} - for sg in game.subgames - } - assert actual == test_case.children - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): - """`game.minimal_subgame(infoset)` returns the smallest subgame containing the infoset.""" - game = test_case.factory() - expected_path_for_key = { - key: path - for path, keys in test_case.differences.items() - for key in keys - } - for player in game.players: - for node in game.get_infosets(player): - key = (node.infoset.player, node.infoset.number) - actual_path = tuple(_get_path_of_action_labels(game.minimal_subgame(node).root)) - assert actual_path == expected_path_for_key[key] - - -@pytest.mark.parametrize("game_file, expected_node_data", [ - ( - "binary_3_levels_generic_payoffs.efg", - [ - # Format: (Path in Node->Root order, (Player Label, Infoset Num, Action Label) or None) - ([], None), - (["Left"], None), - (["Left", "Left"], ("Player 1", 0, "Left")), - (["Right", "Left"], ("Player 1", 0, "Left")), - (["Right"], None), - (["Left", "Right"], ("Player 1", 0, "Right")), - (["Right", "Right"], ("Player 1", 0, "Right")), - ] - ), - ( - gbt.catalog.load("journals/geb/wichardt2008"), - [ - ([], None), - (["R"], ("Player 1", 0, "R")), - (["r", "R"], None), - (["l", "R"], None), - (["L"], ("Player 1", 0, "L")), - (["r", "L"], None), - (["l", "L"], None), - ] - ), - ( - "subgames.efg", - [ - ([], None), - (["1"], None), - (["2"], None), - (["1", "2"], ("Player 2", 0, "2")), - (["2", "1", "2"], ("Player 1", 1, "1")), - (["2", "2"], ("Player 2", 0, "2")), - (["1", "2", "2"], ("Player 2", 1, "1")), - (["1", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "1", "1", "2", "2"], ("Player 2", 2, "1")), - (["2", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "2", "1", "2", "2"], ("Player 2", 2, "2")), - (["2", "2", "1", "2", "2"], ("Player 2", 2, "2")), - (["1", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), - (["1", "1", "2", "2", "1", "2", "2"], ("Player 2", 4, "1")), - (["1", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), - (["2", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), - (["2", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), - (["2", "2", "2"], ("Player 1", 1, "2")), - ] - ), - ( - "AM-driver-subgame.efg", - [ - ([], None), - (["S"], ("Player 1", 0, "S")), - (["T", "S"], None), - ] - ), -]) -def test_node_own_prior_action_non_terminal(game_file, expected_node_data): - """ - Tests `node.own_prior_action` for non-terminal nodes. - Also verifies that all terminal nodes return None. - """ - game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - - actual_node_data = [] - - for node in game.nodes: - if node.is_terminal: - assert node.own_prior_action is None, ( - f"Terminal node at {_get_path_of_action_labels(node)} must be None" - ) - else: - # Only collect data for non-terminal nodes - opa = node.own_prior_action - if opa is not None: - details = (opa.node.infoset.player, opa.node.infoset.number, opa.label) - else: - details = None - actual_node_data.append((_get_path_of_action_labels(node), details)) - - assert actual_node_data == expected_node_data - - -@pytest.mark.parametrize("game_file, expected_unreachable_paths", [ - # Games without absent-mindedness, where all nodes are reachable - (gbt.catalog.load("journals/geb/wichardt2008"), []), - ("subgames.efg", []), - - # An absent-minded driver game with an unreachable terminal node - ( - "AM-driver-one-infoset.efg", - [["T", "S"]] - ), - - # An absent-minded driver game with an unreachable subtree - ( - "AM-driver-subgame.efg", - [["T", "S"], ["r", "T", "S"], ["l", "T", "S"]] - ), -]) -def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]): - """ - Tests `node.is_strategy_reachable` by collecting all unreachable nodes, - converting them to their action-label paths, and comparing the resulting - list of paths against a known-correct list. - """ - game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - nodes = game.nodes - - actual_unreachable_paths = [ - _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable - ] - - assert actual_unreachable_paths == expected_unreachable_paths - - -def test_append_move_error_player_actions(): - """Test to ensure there are actions when appending with a player""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.append_move(game.root, "Player 1", []) - - -def test_append_move_error_infoset_mismatch(): - """Test to ensure the node and the player are from the same game""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.append_infoset(game1.root, game2.root) - - -def test_append_move_error_empty_label(): - """Test that an empty label in `actions` is rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.append_move(game.root, "Player 1", ["a", ""]) - - -def test_append_move_error_duplicate_label(): - """Test that duplicated labels in `actions` are rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.append_move(game.root, "Player 1", ["a", "a"]) - - -def test_insert_move_error_player_actions(): - """Test to ensure there are actions when inserting with a player""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.insert_move(game.root, "Player 1", []) - - -def test_insert_move_error_empty_label(): - """Test that an empty label in `actions` is rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.insert_move(game.root, "Player 1", ["a", ""]) - - -def test_insert_move_error_duplicate_label(): - """Test that duplicated labels in `actions` are rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.insert_move(game.root, "Player 1", ["a", "a"]) - - -def test_node_infoset_becomes_null_when_truncated(): - """A captured infoset proxy re-resolves to null after the node is truncated to a leaf.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - proxy = node.infoset - assert proxy - game.delete_tree(node) - assert not proxy - - -def test_node_delete_parent(): - """Test to ensure deleting a parent node works""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - game.delete_parent(node) - assert game.root == node - - -def test_node_delete_tree(): - """Test to ensure deleting every child of a node works""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - game.delete_tree(node) - assert len(node.children) == 0 - - -def test_node_copy_nonterminal(): - """Test on copying to a nonterminal node.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.copy_tree(game.root, game.root) - - -def test_node_copy_across_games(): - """Test to ensure a gbt.MismatchError is raised when trying to copy a tree - from a different game. - """ - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.copy_tree(game1.root, game2.root) - with pytest.raises(gbt.MismatchError): - game1.copy_tree(game2.root, game1.root) - - -def _subtrees_equal( - n1: gbt.Node, - n2: gbt.Node, - recursion_stop_node: gbt.Node | None = None -) -> bool: - if n1 == recursion_stop_node: - return n2.is_terminal - if n1.is_terminal and n2.is_terminal: - if not n1.outcome and not n2.outcome: - return True - return n1.outcome == n2.outcome - if n1.is_terminal is not n2.is_terminal: - return False - # now, both n1 and n2 are non-terminal - # check that they are in the same infosets - if n1.infoset != n2.infoset: - return False - # check that they have the same number of children - if len(n1.children) != len(n2.children): - return False - - return all( - _subtrees_equal(c1, c2, recursion_stop_node) for (c1, c2) in zip( - n1.children, n2.children, strict=True - ) - ) - - -def test_copy_tree_onto_nondescendent_terminal_node(): - """Test copying a subtree to a non-descendent node.""" - g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"].children["L"] - dest_node = g.root.children["R"].children["R"] - - g.copy_tree(src_node, dest_node) - - assert _subtrees_equal(src_node, dest_node) - - -def test_copy_tree_onto_descendent_terminal_node(): - """Test copying a subtree to a node that's a descendent of the original.""" - g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"] - dest_node = g.root.children["R"].children["L"].children["R"] - - g.copy_tree(src_node, dest_node) - - assert _subtrees_equal(src_node, dest_node, dest_node) - - -def test_node_move_nonterminal(): - """Test on moving to a nonterminal node.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root) - - -def test_node_move_successor(): - """Test on moving a node to one of its successors.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root.children["U1"].children["U2"].children["U3"]) - - -def test_node_move_across_games(): - """Test to ensure a gbt.MismatchError is raised when trying to move a tree - between different games. - """ - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.move_tree(game1.root, game2.root) - with pytest.raises(gbt.MismatchError): - game1.move_tree(game2.root, game1.root) - - -def test_append_move_creates_single_infoset_list_of_nodes(): - """Test that appending a list of nodes creates a single infoset.""" - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - nodes = [game.root.children["2"].children["1"], - game.root.children["1"].children["1"], - game.root.children["1"].children["2"]] - game.append_move(nodes, "Player 3", ["B", "F"]) - assert len(game.get_infosets("Player 3")) == 1 - - -def test_append_move_same_infoset_list_of_nodes(): - """Test that nodes from a list of nodes are resolved in the same infoset.""" - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - game.append_move([node1, node2], "Player 3", ["B", "F"]) - assert node1.infoset == node2.infoset - - -def test_append_move_actions_list_of_nodes(): - """Test that nodes from a list of nodes that resolved in the same infoset - have the same actions. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) - assert list(node1.infoset.actions) == list(node2.infoset.actions) - - -def test_append_move_actions_list_of_node_labels(): - """Test that nodes from a list of node labels are resolved correctly.""" - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - node1.label = "0" - node2.label = "00" - game.append_move(["0", "00"], "Player 3", ["B", "F", "S"]) - - assert node1.children["B"].parent.label == "0" - assert node2.children["B"].parent.label == "00" - assert len(node1.children) == 3 - assert len(node2.children) == 3 - - -def test_append_move_actions_list_of_mixed_node_references(): - """Test that nodes from a list of nodes with either 'node' or str references - are resolved correctly. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - node1.label = "000" - node_references = ["000", node2] - game.append_move(node_references, "Player 3", ["B", "F", "S"]) - - assert node1.children["B"].parent.label == "000" - assert len(node1.children) == 3 - assert len(node2.children) == 3 - - -def test_append_move_labels_list_of_nodes(): - """Test that nodes from a list of nodes that resolved in the same infoset - have the same labels per action. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) - - assert node1.infoset.actions == node2.infoset.actions - - -def test_append_move_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when we import in append_move a list - of nodes that has a non-terminal node. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - with pytest.raises(gbt.UndefinedOperationError): - game.append_move( - [game.root.children["2"], game.root.children["1"].children["2"]], - "Player 3", - ["B", "F"] - ) - - -def test_append_move_node_list_with_duplicate_node_references(): - """Test that we get a ValueError when we import in append_move a list - nodes with non-unique node references. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - node = game.root.children["1"].children["2"] - node.label = "00" - with pytest.raises(ValueError): - game.append_move( - ["00", game.root.children["2"].children["1"], node], - "Player 3", - ["B", "F"] - ) - - -def test_append_move_node_list_is_empty(): - """Test that we get a ValueError when we import in append_move an - empty list of nodes. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - with pytest.raises(ValueError): - game.append_move([], "Player 3", ["B", "F"]) - - -def test_append_infoset_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when we import in append_infoset - a list of nodes that has a non-terminal node. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] - game.append_move(seed_node, "Player 3", ["B", "F"]) - with pytest.raises(gbt.UndefinedOperationError): - game.append_infoset( - [game.root.children["2"], game.root.children["1"].children["2"]], - seed_node - ) - - -def test_append_infoset_node_list_with_duplicate_node(): - """Test that we get a ValueError when we import in append_infoset a list - with non-unique elements. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] - game.append_move(seed_node, "Player 3", ["B", "F"]) - with pytest.raises(ValueError): - game.append_infoset( - [game.root.children["1"].children["2"], - game.root.children["2"].children["1"], - game.root.children["1"].children["2"]], - seed_node - ) - - -def test_append_infoset_node_list_is_empty(): - """Test that we get a ValueError when we import in append_infoset an - empty list of nodes. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] - game.append_move(seed_node, "Player 3", ["B", "F"]) - with pytest.raises(ValueError): - game.append_infoset([], seed_node) - - -def test_append_event_creates_single_event_list_of_nodes(): - """Test that appending a list of nodes creates a single chance event.""" - game = games.read_from_file("sample_extensive_game.efg") - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - game.append_event([node1, node2], ["a", "b"], [gbt.Rational(1, 2)] * 2) - assert node1.event == node2.event - assert node1.event - - -def test_append_event_sets_distribution(): - """Test that the new event's actions carry the given probabilities.""" - game = games.read_from_file("sample_extensive_game.efg") - node = game.root.children["1"].children["1"] - game.append_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) - assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] - - -def test_append_event_error_actions_empty(): - """Test to ensure there are actions when appending an event.""" - game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] - with pytest.raises(gbt.UndefinedOperationError): - game.append_event(terminal, [], []) - - -def test_append_event_error_node_mismatch(): - """Test to ensure the node is from this game.""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.append_event(game2.root, ["a", "b"], [gbt.Rational(1, 2)] * 2) - - -def test_append_event_error_empty_label(): - """Test that an empty label in `actions` is rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] - with pytest.raises(ValueError): - game.append_event(terminal, ["a", ""], [gbt.Rational(1, 2)] * 2) - - -def test_append_event_error_duplicate_label(): - """Test that duplicated labels in `actions` are rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] - with pytest.raises(ValueError): - game.append_event(terminal, ["a", "a"], [gbt.Rational(1, 2)] * 2) - - -def test_append_event_error_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when the node list has a - non-terminal node. - """ - game = games.read_from_file("sample_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.append_event( - [game.root.children["2"], game.root.children["1"].children["2"]], - ["a", "b"], - [gbt.Rational(1, 2)] * 2 - ) - - -def test_append_event_error_node_list_with_duplicate_node_references(): - """Test that we get a ValueError when the node list has non-unique node references.""" - game = games.read_from_file("sample_extensive_game.efg") - node = game.root.children["1"].children["2"] - with pytest.raises(ValueError): - game.append_event([node, node], ["a", "b"], [gbt.Rational(1, 2)] * 2) - - -def test_append_event_error_node_list_is_empty(): - """Test that we get a ValueError when the node list is empty.""" - game = games.read_from_file("sample_extensive_game.efg") - with pytest.raises(ValueError): - game.append_event([], ["a", "b"], [gbt.Rational(1, 2)] * 2) - - -def test_append_event_error_invalid_distribution(): - """Test that a distribution which does not sum to one is rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] - with pytest.raises(ValueError): - game.append_event(terminal, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) - - -def test_insert_event_actions_labeled(): - """Test that the inserted event's actions are labeled according to `actions`.""" - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = game.root.children["L"].children["R"] - game.insert_event(node, ["Up", "Down"], [gbt.Rational(1, 2)] * 2) - assert list(node.parent.actions) == ["Up", "Down"] - assert node.parent.event - - -def test_insert_event_sets_distribution(): - """Test that the inserted event's actions carry the given probabilities.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root - game.insert_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) - assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] - - -def test_insert_event_error_actions_empty(): - """Test to ensure there are actions when inserting an event.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.insert_event(game.root, [], []) - - -def test_insert_event_error_node_mismatch(): - """Test to ensure the node is from this game.""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.insert_event(game2.root, ["a", "b"], [gbt.Rational(1, 2)] * 2) - - -def test_insert_event_error_empty_label(): - """Test that an empty label in `actions` is rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.insert_event(game.root, ["a", ""], [gbt.Rational(1, 2)] * 2) - - -def test_insert_event_error_duplicate_label(): - """Test that duplicated labels in `actions` are rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.insert_event(game.root, ["a", "a"], [gbt.Rational(1, 2)] * 2) - - -def test_insert_event_error_invalid_distribution(): - """Test that a distribution which does not sum to one is rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.insert_event(game.root, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) - - -def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: - """Counts nodes in the subtree rooted at `start_node` (including `start_node`). - - Parameters - ---------- - start_node: Node - The root of the subtree - count_terminal: bool - Include or exclude terminal nodes from count - """ - count = 1 if count_terminal or not start_node.is_terminal else 0 - - for child in start_node.children: - count += _count_subtree_nodes(child, count_terminal) - return count - - -def test_len_matches_expected_node_count(): - """Verify `len(game.nodes)` matches expected node count - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - expected_node_count = 9 - - direct_len = len(game.nodes) - assert direct_len == expected_node_count - - assert direct_len == _count_subtree_nodes(game.root, True) - - -def test_len_after_delete_tree(): - """Verify `len(game.nodes)` is correct after `delete_tree`. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - - root_of_the_deleted_subtree = game.root.children["R"].children["L"] - number_of_deleted_nodes = _count_subtree_nodes(root_of_the_deleted_subtree, True) - 1 - - game.delete_tree(root_of_the_deleted_subtree) - - assert len(game.nodes) == initial_number_of_nodes - number_of_deleted_nodes - - -def test_len_after_delete_parent(): - """Verify `len(game.nodes)` is correct after `delete_parent`. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) - - node_parent_to_delete = game.root.children["L"].children["L"] - - number_of_node_ancestors = _count_subtree_nodes(node_parent_to_delete, True) - number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent, True) - diff = number_of_parent_ancestors - number_of_node_ancestors - - game.delete_parent(node_parent_to_delete) - - assert len(game.nodes) == initial_number_of_nodes - diff - - -def test_len_after_append_move(): - """Verify `len(game.nodes)` is correct after `append_move`.""" - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - - terminal_node = game.root.children["R"].children["L"].children["L"] # the [1,1,0] terminal - player = "Player 1" - actions_to_add = ["T", "M", "B"] - - game.append_move(terminal_node, player, actions_to_add) - - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) - - -def test_len_after_append_infoset(): - """Verify `len(game.nodes)` is correct after `append_infoset`. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) - - member_node = game.root.children["L"] - infoset_to_modify = member_node.infoset - number_of_infoset_actions = len(infoset_to_modify.actions) - terminal_node_to_add = game.root.children["L"].children["L"].children["l"] - - game.append_infoset(terminal_node_to_add, member_node) - - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions - - -def test_len_after_set_move_actions_add(): - """Verify `len(game.nodes)` is correct after `set_move_actions` creates an action.""" - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - infoset_to_modify = game.root.children["L"].infoset # Player 2's infoset - num_nodes_in_infoset = len(infoset_to_modify.members) - labels = list(infoset_to_modify.actions) - game.set_move_actions(game.root.children["L"], labels + ["new"]) - assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset - - -def test_len_after_set_move_actions_drop(): - """Verify `len(game.nodes)` is correct after `set_move_actions` deletes an action.""" - game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) - action_to_drop = "L" - nodes_to_delete = sum( - _count_subtree_nodes(member.children[action_to_drop], True) - for member in game.root.infoset.members - ) - remaining = [a for a in game.root.infoset.actions if a != "L"] - game.set_move_actions(game.root, remaining, drop=True) - assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete - - -def test_len_after_insert_move(): - """Verify `len(game.nodes)` is correct after `insert_move`.""" - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - - node_to_insert_above = game.root.children["L"].children["R"] # the [1, 0] node - player = "Player 2" - actions_to_add = ["a", "b", "c"] - - game.insert_move(node_to_insert_above, player, actions_to_add) - - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) - - -def test_insert_move_actions_labeled(): - """Test that the inserted move's actions are labeled according to `actions`.""" - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = game.root.children["L"].children["R"] - game.insert_move(node, "Player 2", ["Up", "Down"]) - assert list(node.parent.infoset.actions) == ["Up", "Down"] - - -def test_len_after_insert_infoset(): - """Verify `len(game.nodes)` is correct after `insert_infoset`. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - - infoset_to_modify = game.root.children["L"].infoset - node_to_insert_above = game.root.children["L"].children["R"] - number_of_infoset_actions = len(infoset_to_modify.actions) - - game.insert_infoset(node_to_insert_above, game.root.children["L"]) - - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions - - -def test_len_after_copy_tree(): - """Verify `len(game.nodes)` is correct after `copy_tree`. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - src_node = game.root.children["R"].children["L"] - dest_node = game.root.children["R"].children["R"] - number_of_src_ancestors = _count_subtree_nodes(src_node, True) - - game.copy_tree(src_node, dest_node) - - assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 - - -def test_node_plays(): - """Verify `node.plays` returns plays reachable from a given node. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - - test_node = game.root.children["L"] - - expected_set_of_plays = { - game.root.children["L"].children["R"], - game.root.children["L"].children["L"].children["r"], - game.root.children["L"].children["L"].children["l"], - } - - assert set(test_node.plays) == expected_set_of_plays - - -def test_node_children_action_label(): - """Label lookup returns the correct child. - - The RHS reaches the child positionally (independent of ``__getitem__``); a label - on both sides would make the assertion circular. - """ - game = games.read_from_file("stripped_down_poker.efg") - root_children = list(game.root.children) - assert game.root.children["King"] == root_children[0] - assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] - - -def test_node_children_empty_label(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError, match="empty or all whitespace"): - _ = game.root.children[" "] - - -def test_node_children_terminal_node(): - game = games.read_from_file("stripped_down_poker.efg") - terminal = next(n for n in game.nodes if n.is_terminal) - with pytest.raises(KeyError, match="No action with label"): - _ = terminal.children["Bet"] - - -def test_node_children_nonexistent_action(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(KeyError, match="No action with label 'Jack'"): - _ = game.root.children["Jack"] - - -def test_node_children_rejects_int(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(TypeError, match="16.7.0"): - _ = game.root.children[0] - - -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_node_label_valid(label): - game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label - - -def test_node_label_duplicate_raises_valueerror(): - game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "shared" - with pytest.raises(ValueError): - game.root.children["U1"].label = "shared" - - -def test_node_label_empty_is_allowed(): - """Node labels may be empty (unlike outcomes/players); multiple empties coexist.""" - game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "" - game.root.children["U1"].label = "" - assert game.root.label == "" - assert game.root.children["U1"].label == "" - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_node_label_invalid_raises_valueerror(label): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.root.label = label - - -@pytest.mark.parametrize("label", games.UNICODE_LABELS) -def test_node_label_unicode_accepted(label): - """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" - game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label - - -@pytest.mark.parametrize( - "game_obj", - [ - pytest.param(games.read_from_file("basic_extensive_game.efg")), - pytest.param(games.read_from_file("binary_3_levels_generic_payoffs.efg")), - pytest.param(games.read_from_file("cent3.efg")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig1")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2")), - pytest.param(games.read_from_file("stripped_down_poker.efg")), - pytest.param(gbt.Game.new_tree()), - ], -) -def test_nodes_iteration_order(game_obj: gbt.Game): - """Verify that the C++ `game.nodes` iterator produces the DFS traversal. - """ - def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: - yield node - for child in node.children: - yield from dfs(child) - - assert all(a == b for a, b in itertools.zip_longest(game_obj.nodes, dfs(game_obj.root))) diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py new file mode 100644 index 0000000000..d89417d540 --- /dev/null +++ b/tests/test_outcome_mutations.py @@ -0,0 +1,194 @@ +import pytest + +import pygambit as gbt + + +def test_make_outcome_attaches_to_all_given_nodes(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" + ) + assert game.get_outcome(gbt.H.path("U")) == "shared" + assert game.get_outcome(gbt.H.path("M")) == "shared" + assert game.get_outcome(gbt.H.path("D")) is None + payoffs = game.get_outcome_payoffs("shared") + assert payoffs["Alice"] == 1 + assert payoffs["Bob"] == -1 + + +def test_make_outcome_accepts_selector(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") + assert game.get_outcome(gbt.H.path("U")) == "shared" + assert game.get_outcome(gbt.H.path("M")) is None + assert game.get_outcome(gbt.H.path("D")) is None + + +def test_make_outcome_accepts_selector_matching_several_nodes(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") + assert game.get_outcome(gbt.H.path("U")) == "shared" + assert game.get_outcome(gbt.H.path("M")) == "shared" + assert game.get_outcome(gbt.H.path("D")) == "shared" + + +def test_make_outcome_accepts_grouped_selector_pooled(): + """A `GroupedSelector`'s groups are pooled together: every matched node + receives the same outcome, regardless of grouping.""" + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome( + gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" + ) + assert game.get_outcome(gbt.H.path("U")) == "shared" + assert game.get_outcome(gbt.H.path("M")) == "shared" + assert game.get_outcome(gbt.H.path("D")) == "shared" + + +def test_make_outcome_error_location_not_a_selector(): + """A bare `History` tuple is no longer accepted for an extensive game.""" + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + with pytest.raises(TypeError): + game.make_outcome(("U",), {"Alice": 1}, "w") + + +def test_make_outcome_attaches_at_contingencies(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome( + [{"1": "1", "2": "1"}, {"1": "2", "2": "2"}], {"1": 2, "2": -2}, "diagonal" + ) + assert game.get_outcome({"1": "1", "2": "1"}) == "diagonal" + assert game.get_outcome({"1": "2", "2": "2"}) == "diagonal" + assert not game.get_outcome({"1": "1", "2": "2"}) + assert game.get_outcome_payoffs("diagonal")["1"] == 2 + + +def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + game.make_outcome(gbt.H.path("U"), {"Alice": 1}, "w") + game.make_outcome(gbt.H.path(...), {"Alice": 2}, "w") + assert game.get_outcomes() == ["w"] + assert game.get_outcome_payoffs("w")["Alice"] == 2 + + +def test_make_outcome_label_of_partially_covered_outcome_refused(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "w") + with pytest.raises(ValueError): + game.make_outcome(gbt.H.path("D"), {"Alice": 2}, "w") + assert len(game.get_outcomes()) == 1 + + +@pytest.mark.parametrize("bad_label", ["", "win"]) +def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["win", "lose"]) + game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") + with pytest.raises(ValueError): + game.make_outcome(gbt.H.path("lose"), {"A": 3, "B": 4}, bad_label) + assert game.get_outcomes() == ["win"] + + +def test_make_outcome_incomplete_payoffs_raises(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + with pytest.raises(ValueError): + game.make_outcome(gbt.H.path("U"), {"Alice": 1}, "w") + + +class _RepeatedEntryPayoffs: + """A Mapping-like object whose `.items()` may repeat a key. + + Used to exercise `make_outcome`'s "named twice" check, which a plain + ``dict`` literal cannot: duplicate string keys collapse before the + dict is ever constructed. + """ + + def __init__(self, entries): + self._entries = entries + + def items(self): + return self._entries + + +def test_make_outcome_payoffs_naming_player_twice_raises(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + payoffs = _RepeatedEntryPayoffs([("Alice", 1), ("Alice", 2), ("Bob", 0)]) + with pytest.raises(ValueError): + game.make_outcome(gbt.H.path("U"), payoffs, "w") + + +def test_make_outcome_null_accepts_selector(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" + ) + game.make_outcome_null(gbt.H.path("U")) + assert game.get_outcome(gbt.H.path("U")) is None + assert game.get_outcome(gbt.H.path("M")) is not None + assert game.get_outcome(gbt.H.path("D")) is None + + +def test_make_outcome_null_error_location_not_a_selector(): + """A bare `History` tuple is no longer accepted for an extensive game.""" + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + with pytest.raises(TypeError): + game.make_outcome_null(("U",)) + + +def test_make_outcome_null_resets_given_contingencies_to_null(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome( + [{"1": "1", "2": "1"}, {"1": "2", "2": "2"}], {"1": 2, "2": -2}, "diagonal" + ) + game.make_outcome_null({"1": "1", "2": "1"}) + assert not game.get_outcome({"1": "1", "2": "1"}) + assert game.get_outcome({"1": "2", "2": "2"}) + + +def test_make_outcome_null_removes_fully_orphaned_outcome(): + game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) + outcome_count = len(game.get_outcomes()) + p1, p2 = game.players + s1 = next(iter(game.get_strategies(p1))) + s2 = next(iter(game.get_strategies(p2))) + game.make_outcome_null({p1: s1, p2: s2}) + assert len(game.get_outcomes()) == outcome_count - 1 + + +def test_make_outcome_null_keeps_partially_referenced_outcome(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "shared") + outcome_count = len(game.get_outcomes()) + game.make_outcome_null(gbt.H.path("U")) + assert len(game.get_outcomes()) == outcome_count + assert game.get_outcome(gbt.H.path("M")) is not None + + +def test_make_outcome_null_on_already_null_node_is_a_no_op(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + outcome_count = len(game.get_outcomes()) + game.make_outcome_null(gbt.H.path("U")) + assert outcome_count == len(game.get_outcomes()) + assert game.get_outcome(gbt.H.path("U")) is None + + +def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["win", "lose"]) + game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") + game.make_outcome(gbt.H.path("lose"), {"A": 0, "B": 0}, "lose") + with pytest.raises(ValueError): + game.relabel_outcomes({"lose": "win"}) + assert set(game.get_outcomes()) == {"win", "lose"} diff --git a/tests/test_outcome_queries.py b/tests/test_outcome_queries.py new file mode 100644 index 0000000000..5b19164386 --- /dev/null +++ b/tests/test_outcome_queries.py @@ -0,0 +1,84 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def _labeled_table_game() -> gbt.Game: + """A 2x2 table game whose two outcomes are each given a distinct, nonempty + label at creation, so they can be addressed by `relabel_outcomes`/ + `get_outcome_payoffs`/`set_outcome_payoffs`.""" + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "o1") + game.make_outcome([{"1": "1", "2": "2"}, {"1": "2", "2": "1"}, {"1": "2", "2": "2"}], + {"1": 0, "2": 0}, "o2") + return game + + +@pytest.mark.parametrize("label", games.VALID_LABELS) +def test_outcome_relabel(label: str): + game = _labeled_table_game() + game.relabel_outcomes({"o1": label}) + assert label in game.get_outcomes() + + +@pytest.mark.parametrize("label", games.INVALID_LABELS) +def test_outcome_relabel_invalid_raises_valueerror(label: str): + game = _labeled_table_game() + with pytest.raises(ValueError): + game.relabel_outcomes({"o1": label}) + + +@pytest.mark.parametrize("label", games.UNICODE_LABELS) +def test_outcome_relabel_unicode_accepted(label: str): + """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" + game = _labeled_table_game() + game.relabel_outcomes({"o1": label}) + assert label in game.get_outcomes() + + +@pytest.mark.parametrize( + "game", [gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]])] +) +def test_outcome_payoffs_unmatched_label_raises_keyerror(game: gbt.Game): + with pytest.raises(KeyError): + _ = game.get_outcome_payoffs("not an outcome") + + +@pytest.mark.parametrize( + "game", [gbt.Game.new_table([2, 2])] +) +def test_outcome_payoffs_invalid_label_type_raises_typeerror(game: gbt.Game): + with pytest.raises(TypeError): + _ = game.get_outcome_payoffs(1.3) + + +@pytest.mark.parametrize( + "game", + [ + gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]), + gbt.Game.from_dict({"a": [[0, 0], [0, 0]], "b": [[0, 0], [0, 0]]}), + ], + ids=["from_arrays", "from_dict"], +) +def test_outcomes_have_unique_nonempty_labels_at_construction(game: gbt.Game): + """`from_arrays`/`from_dict` eagerly create one outcome per contingency + (unlike the sparse-by-default `Game.new_table`); each of those must still + satisfy the invariant that every non-null outcome has a unique, nonempty + label.""" + labels = game.get_outcomes() + assert len(labels) == 4 + assert all(label for label in labels) + assert len(set(labels)) == len(labels) + + +def test_outcome_payoff_by_player_label(): + game = _labeled_table_game() + game.relabel_players({"1": "joe", "2": "dan"}) + game.set_outcome_payoffs("o1", {"joe": 1, "dan": 2}) + game.set_outcome_payoffs("o2", {"joe": 3, "dan": 4}) + assert game.get_outcome_payoffs("o1")["joe"] == 1 + assert game.get_outcome_payoffs("o1")["dan"] == 2 + assert game.get_outcome_payoffs("o2")["joe"] == 3 + assert game.get_outcome_payoffs("o2")["dan"] == 4 diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py deleted file mode 100644 index b34dc1ac9b..0000000000 --- a/tests/test_outcomes.py +++ /dev/null @@ -1,218 +0,0 @@ -import pytest - -import pygambit as gbt - -from . import games - - -def test_make_outcome_attaches_to_all_given_nodes(): - game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - outcome = game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") - assert up.outcome == outcome - assert middle.outcome == outcome - assert not down.outcome - assert outcome["Alice"] == 1 - assert outcome["Bob"] == -1 - - -def test_make_outcome_attaches_at_contingencies(): - game = gbt.Game.new_table([2, 2]) - outcome = game.make_outcome( - [{"1": "1", "2": "1"}, {"1": "2", "2": "2"}], {"1": 2, "2": -2}, "diagonal" - ) - assert game.get_outcome({"1": "1", "2": "1"}) == outcome - assert game.get_outcome({"1": "2", "2": "2"}) == outcome - assert not game.get_outcome({"1": "1", "2": "2"}) - assert outcome["1"] == 2 - - -def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "D"]) - up, down = game.root.children - game.make_outcome(up, {"Alice": 1}, "w") - game.make_outcome([up, down], {"Alice": 2}, "w") - assert [(o.label, o["Alice"]) for o in game.outcomes] == [("w", 2)] - - -def test_make_outcome_label_of_partially_covered_outcome_refused(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1}, "w") - with pytest.raises(ValueError): - game.make_outcome(down, {"Alice": 2}, "w") - assert len(game.outcomes) == 1 - - -@pytest.mark.parametrize("bad_label", ["", "win"]) -def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str): - game = gbt.Game.new_tree(players=["A", "B"]) - game.append_move(game.root, "A", ["win", "lose"]) - win_node, lose_node = game.root.children - game.make_outcome(win_node, {"A": 1, "B": 2}, "win") - with pytest.raises(ValueError): - game.make_outcome(lose_node, {"A": 3, "B": 4}, bad_label) - assert [o.label for o in game.outcomes] == ["win"] - - -def test_make_outcome_incomplete_payoffs_raises(): - game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "D"]) - with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), {"Alice": 1}, "w") - - -class _RepeatedEntryPayoffs: - """A Mapping-like object whose `.items()` may repeat a key. - - Used to exercise `make_outcome`'s "named twice" check, which a plain - ``dict`` literal cannot: duplicate string keys collapse before the - dict is ever constructed. - """ - - def __init__(self, entries): - self._entries = entries - - def items(self): - return self._entries - - -def test_make_outcome_payoffs_naming_player_twice_raises(): - game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "D"]) - payoffs = _RepeatedEntryPayoffs([("Alice", 1), ("Alice", 2), ("Bob", 0)]) - with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), payoffs, "w") - - -def test_make_outcome_null_resets_given_nodes_to_null(): - game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") - game.make_outcome_null(up) - assert not up.outcome - assert middle.outcome - assert not down.outcome - - -def test_make_outcome_null_resets_given_contingencies_to_null(): - game = gbt.Game.new_table([2, 2]) - game.make_outcome( - [{"1": "1", "2": "1"}, {"1": "2", "2": "2"}], {"1": 2, "2": -2}, "diagonal" - ) - game.make_outcome_null({"1": "1", "2": "1"}) - assert not game.get_outcome({"1": "1", "2": "1"}) - assert game.get_outcome({"1": "2", "2": "2"}) - - -def test_make_outcome_null_removes_fully_orphaned_outcome(): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome_count = len(game.outcomes) - p1, p2 = game.players - s1 = next(iter(game.get_strategies(p1))) - s2 = next(iter(game.get_strategies(p2))) - game.make_outcome_null({p1: s1, p2: s2}) - assert len(game.outcomes) == outcome_count - 1 - - -def test_make_outcome_null_keeps_partially_referenced_outcome(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1}, "shared") - outcome_count = len(game.outcomes) - game.make_outcome_null(up) - assert len(game.outcomes) == outcome_count - assert middle.outcome - - -def test_make_outcome_null_on_already_null_node_is_a_no_op(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "D"]) - up, _ = game.root.children - outcome_count = len(game.outcomes) - game.make_outcome_null(up) - assert outcome_count == len(game.outcomes) - assert not up.outcome - - -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_outcome_label(label: str): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome.label == label - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_outcome_label_invalid_raises_valueerror(label: str): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - with pytest.raises(ValueError): - outcome.label = label - - -@pytest.mark.parametrize("label", games.UNICODE_LABELS) -def test_outcome_label_unicode_accepted(label: str): - """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome.label == label - - -@pytest.mark.parametrize( - "game,label", - [(gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]), "outcome label")] -) -def test_outcome_index_label(game: gbt.Game, label: str): - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome == game.outcomes[label] - assert game.outcomes[label].label == label - - -@pytest.mark.parametrize( - "game", [gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]])] -) -def test_outcome_index_unmatched_label(game: gbt.Game): - with pytest.raises(KeyError): - _ = game.outcomes["not an outcome"] - - -@pytest.mark.parametrize( - "game", [gbt.Game.new_table([2, 2])] -) -def test_outcome_index_invalid_type(game: gbt.Game): - with pytest.raises(TypeError): - _ = game.outcomes[1.3] - - -def test_outcome_payoff_by_player_label(): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - pl1, pl2 = list(game.players) - game.relabel_players({pl1: "joe", pl2: "dan"}) - out1, out2, *_ = list(game.outcomes) - out1["joe"] = 1 - out1["dan"] = 2 - out2["joe"] = 3 - out2["dan"] = 4 - assert out1["joe"] == 1 - assert out1["dan"] == 2 - assert out2["joe"] == 3 - assert out2["dan"] == 4 - - -def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): - game = gbt.Game.new_tree(players=["A", "B"]) - game.append_move(game.root, "A", ["win", "lose"]) - win_node, lose_node = game.root.children - game.make_outcome(win_node, {"A": 1, "B": 2}, "win") - outcome = game.make_outcome(lose_node, {"A": 0, "B": 0}, "lose") - with pytest.raises(ValueError): - outcome.label = "win" - assert outcome.label == "lose" diff --git a/tests/test_players.py b/tests/test_players.py index 76708e2fda..b678d46844 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -305,7 +305,7 @@ def test_player_sequence_count(): game = gbt.catalog.load("books/myerson1991/fig2_1") for player in game.players: action_count = sum( - len(node.infoset.actions) for node in game.get_infosets(player) + len(game.get_actions(gbt.H.path(*history))) for history in game.get_infosets(player) ) assert len(game.get_sequences(player)) == action_count + 1 @@ -317,8 +317,8 @@ def test_player_sequence_actions(): reference = ( set( (action, ) - for node in game.get_infosets(player) - for action in node.infoset.actions + for history in game.get_infosets(player) + for action in game.get_actions(gbt.H.path(*history)) ) | {tuple()} ) @@ -375,7 +375,7 @@ def test_player_get_min_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.get_min_payoff("Alice") == -2 assert game.get_min_payoff("Bob") == -2 - game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") + game.make_outcome(gbt.H.path(), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_min_payoff("Alice") == -3 assert game.get_min_payoff("Bob") == -3 @@ -401,7 +401,7 @@ def test_player_get_max_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.get_max_payoff("Alice") == 2 assert game.get_max_payoff("Bob") == 2 - game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") + game.make_outcome(gbt.H.path(), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_max_payoff("Alice") == 1 assert game.get_max_payoff("Bob") == 1 @@ -458,7 +458,7 @@ def test_set_players_add_then_drop_round_trips(): game = gbt.Game.from_arrays([[1, 2], [3, 4]], [[5, 6], [7, 8]]) labels = list(game.players) game.set_players(labels + ["X"]) - assert all(outcome["X"] == 0 for outcome in game.outcomes) + assert all(game.get_payoffs(c)["X"] == 0 for c in game.contingencies) game.set_players(labels, drop=True) assert list(game.players) == labels assert game.to_arrays()[0].tolist() == [[1, 2], [3, 4]] diff --git a/tests/test_profile_invalidation.py b/tests/test_profile_invalidation.py new file mode 100644 index 0000000000..dfad65bfc3 --- /dev/null +++ b/tests/test_profile_invalidation.py @@ -0,0 +1,130 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def test_mixed_strategy_profile_game_structure_changed_no_tree(): + game = gbt.Game.from_arrays([[2, 2], [0, 0]], [[0, 0], [1, 1]]) + profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] + player = next(iter(game.players)) + distribution = {s: 0 for s in game.get_strategies(player)} + game.make_outcome( + {p: "1" for p in game.players}, + {p: (3 if p == player else 0) for p in game.players}, + "trigger", + ) + for profile in profiles: + with pytest.raises(gbt.GameStructureChangedError): + profile.copy() + with pytest.raises(gbt.GameStructureChangedError): + profile.liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.max_regret() + with pytest.raises(gbt.GameStructureChangedError): + profile.normalize() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.payoffs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.player_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_values + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + profile.__setitem__(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.set_mixed_strategy(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.__getitem__(player) + + +def test_mixed_strategy_profile_game_structure_changed_tree(): + game = games.read_from_file("basic_extensive_game.efg") + profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] + player = next(iter(game.players)) + game.set_move_actions(gbt.H.path(), ["D1"], drop=True) + distribution = {s: 0 for s in game.get_strategies(player)} + for profile in profiles: + with pytest.raises(gbt.GameStructureChangedError): + profile.as_behavior() + with pytest.raises(gbt.GameStructureChangedError): + profile.copy() + with pytest.raises(gbt.GameStructureChangedError): + profile.liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.max_regret() + with pytest.raises(gbt.GameStructureChangedError): + profile.normalize() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.payoffs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.player_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_values + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + profile.__setitem__(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.set_mixed_strategy(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.__getitem__(player) + + +def test_mixed_behavior_profile_game_structure_changed(): + game = games.read_from_file("basic_extensive_game.efg") + profiles = [game.mixed_behavior_profile(rational=b) for b in [False, True]] + game.set_move_actions(gbt.H.path(), ["D1"], drop=True) + for profile in profiles: + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.action_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.action_values + with pytest.raises(gbt.GameStructureChangedError): + profile.as_strategy() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.beliefs + with pytest.raises(gbt.GameStructureChangedError): + profile.copy() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.infoset_probs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.infoset_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.infoset_values + with pytest.raises(gbt.GameStructureChangedError): + profile.agent_liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.agent_max_regret() + with pytest.raises(gbt.GameStructureChangedError): + profile.max_regret() + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.node_values + with pytest.raises(gbt.GameStructureChangedError): + profile.normalize() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.payoffs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.realiz_probs + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + profile.__setitem__(gbt.H.path(), {}) + with pytest.raises(gbt.GameStructureChangedError): + profile.set_mixed_action(gbt.H.path(), {}) + with pytest.raises(gbt.GameStructureChangedError): + profile.__getitem__(gbt.H.path()) diff --git a/tests/test_qre.py b/tests/test_qre.py index fd5770edcf..81ce559e64 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -13,9 +13,11 @@ def _asymmetric_poker_behavior_data() -> gbt.MixedBehaviorProfile: game = games.create_stripped_down_poker_efg() data = game.mixed_behavior_profile(rational=False) for player in game.players: - for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - data[node] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} + for history in games.player_infosets(game, player): + selector = gbt.H.path(*history) + data[selector] = { + a: float(i + 2) for i, a in enumerate(game.get_actions(selector)) + } return data @@ -73,8 +75,8 @@ def test_logit_estimate_behavior_completes(use_empirical: bool, local_max: bool) result = gbt.qre.logit_estimate(data, use_empirical=use_empirical, local_max=local_max) assert isinstance(result.profile, gbt.MixedBehaviorProfileDouble) for player in data.game.players: - for infoset in games.player_infosets(data.game, player): - node = next(iter(infoset.members)) - probs = dict(result.profile[node]) - assert probs.keys() == set(infoset.actions) + for history in games.player_infosets(data.game, player): + selector = gbt.H.path(*history) + probs = dict(result.profile[selector]) + assert probs.keys() == set(data.game.get_actions(selector)) assert sum(probs.values()) == pytest.approx(1.0) diff --git a/tests/test_strategic.py b/tests/test_strategic_mutations.py similarity index 79% rename from tests/test_strategic.py rename to tests/test_strategic_mutations.py index cffb854978..2f7e711dad 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic_mutations.py @@ -5,51 +5,6 @@ from . import games -def test_strategic_game_get_infosets(): - game = gbt.Game.new_table([2, 2]) - player, _ = game.players - with pytest.raises(gbt.UndefinedOperationError): - _ = game.get_infosets(player) - - -def test_strategic_game_root(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.root - - -def test_strategic_game_nodes(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.nodes - - -def test_game_behav_profile_error(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.mixed_behavior_profile() - - -def test_game_is_const_sum(): - game = games.read_from_file("const_sum_game.nfg") - assert game.is_const_sum - - -def test_game_is_not_const_sum(): - game = games.read_from_file("non_const_sum_game.nfg") - assert not game.is_const_sum - - -def test_game_get_min_payoff(): - game = games.read_from_file("mixed_strategy.nfg") - assert game.min_payoff == 0 - - -def test_game_get_max_payoff(): - game = games.read_from_file("mixed_strategy.nfg") - assert game.max_payoff == 3 - - def test_relabel_strategies_swap(): """Swap is well-defined; strategies keep their positions.""" game = gbt.Game.new_table([2, 2]) diff --git a/tests/test_strategic_queries.py b/tests/test_strategic_queries.py new file mode 100644 index 0000000000..e6361e650a --- /dev/null +++ b/tests/test_strategic_queries.py @@ -0,0 +1,130 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def test_strategic_game_get_infosets(): + game = gbt.Game.new_table([2, 2]) + player, _ = game.players + with pytest.raises(gbt.UndefinedOperationError): + _ = game.get_infosets(player) + + +def test_strategic_game_get_histories_root_raises(): + """A bare `H.path()` (the root) still resolves through the same + tree-only guard as `H.after()`, just via a different internal path.""" + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + _ = game.get_histories(gbt.H.path()) + + +def test_game_behav_profile_error(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + _ = game.mixed_behavior_profile() + + +def test_game_is_const_sum(): + game = games.read_from_file("const_sum_game.nfg") + assert game.is_const_sum + + +def test_game_is_not_const_sum(): + game = games.read_from_file("non_const_sum_game.nfg") + assert not game.is_const_sum + + +def test_game_get_min_payoff(): + game = games.read_from_file("mixed_strategy.nfg") + assert game.min_payoff == 0 + + +def test_game_get_max_payoff(): + game = games.read_from_file("mixed_strategy.nfg") + assert game.max_payoff == 3 + + +def test_game_get_outcome(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "top left") + assert game.get_outcome({"1": "1", "2": "1"}) == "top left" + + +def test_game_get_outcome_by_relabeled_strategies(): + game = gbt.Game.new_table([2, 2]) + pl1, pl2 = game.players + game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) + game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) + game.make_outcome({pl1: "defect", pl2: "cooperate"}, {"1": 0, "2": 0}, "corner") + assert game.get_outcome({pl1: "defect", pl2: "cooperate"}) == "corner" + + +def test_game_get_outcome_incomplete_contingency_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(ValueError): + _ = game.get_outcome({"1": "1"}) + + +def test_game_get_outcome_unknown_player_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(KeyError): + _ = game.get_outcome({"1": "1", "2": "1", "3": "1"}) + + +def test_game_get_outcome_non_mapping_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(TypeError): + _ = game.get_outcome(42) + + +def test_game_get_outcome_non_str_value_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(TypeError): + _ = game.get_outcome({"1": 1.23, "2": "1"}) + + +def test_game_get_outcome_unknown_strategy_label_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(KeyError): + _ = game.get_outcome({"1": "1", "2": "99"}) + + +def test_game_get_outcome_unmatched_label_after_relabel_raises(): + game = gbt.Game.new_table([2, 2]) + pl1, pl2 = game.players + game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) + game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) + with pytest.raises(KeyError): + _ = game.get_outcome({pl1: "defect", pl2: "defect"}) + + +def test_game_get_outcome_tree_rejects_contingency(): + """A pure-strategy contingency (a Mapping) is only meaningful for a + strategic game; for a tree game, `location` must be a `Selector`.""" + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) + with pytest.raises(TypeError): + _ = game.get_outcome({"Alice": "a"}) + + +def test_game_get_payoffs(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 3, "2": -3}, "top left") + payoffs = game.get_payoffs({"1": "1", "2": "1"}) + assert payoffs["1"] == 3 + assert payoffs["2"] == -3 + + +def test_game_get_payoffs_tree(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) + selector = gbt.H.path() + strategy = next( + s for s in game.get_strategies("Alice") + if game.get_behavior("Alice", s).get(selector) == "a" + ) + game.make_outcome(gbt.H.path("a"), {"Alice": 1}, "a-outcome") + payoffs = game.get_payoffs({"Alice": strategy}) + assert payoffs["Alice"] == 1 diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py new file mode 100644 index 0000000000..96881f4fd2 --- /dev/null +++ b/tests/test_tree_mutations.py @@ -0,0 +1,997 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def test_append_move_error_player_actions(): + """Test to ensure there are actions when appending with a player""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.append_move(gbt.H.path(), "Player 1", []) + + +def test_append_move_error_empty_label(): + """Test that an empty label in `actions` is rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.append_move(gbt.H.path(), "Player 1", ["a", ""]) + + +def test_append_move_error_duplicate_label(): + """Test that duplicated labels in `actions` are rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.append_move(gbt.H.path(), "Player 1", ["a", "a"]) + + +def test_insert_move_error_player_actions(): + """Test to ensure there are actions when inserting with a player""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.insert_move(gbt.H.path(), "Player 1", []) + + +def test_insert_move_error_empty_label(): + """Test that an empty label in `actions` is rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.insert_move(gbt.H.path(), "Player 1", ["a", ""]) + + +def test_insert_move_error_duplicate_label(): + """Test that duplicated labels in `actions` are rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.insert_move(gbt.H.path(), "Player 1", ["a", "a"]) + + +def test_node_delete_tree(): + """Test to ensure deleting every child of a node works""" + game = games.read_from_file("basic_extensive_game.efg") + game.delete_tree(gbt.H.path("U1")) + assert not game.get_actions(gbt.H.path("U1")) + + +def test_node_copy_nonterminal(): + """Test on copying to a nonterminal node.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.copy_tree(gbt.H.path(), gbt.H.path()) + + +def test_node_move_nonterminal(): + """Test on moving to a nonterminal node.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.move_tree(gbt.H.path(), gbt.H.path()) + + +def test_node_move_successor(): + """Test on moving a node to one of its successors.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.move_tree(gbt.H.path(), gbt.H.path("U1", "U2", "U3")) + + +def test_append_move_creates_single_infoset_list_of_nodes(): + """Test that appending a Selector matching several nodes creates a single + infoset.""" + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + matches = (("2", "1"), ("1", "1"), ("1", "2")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F"] + ) + assert len(game.get_infosets("Player 3")) == 1 + + +def test_append_move_same_infoset_list_of_nodes(): + """Test that nodes matched by a Selector are resolved in the same infoset.""" + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F"] + ) + assert ("2", "1") in game.get_members(gbt.H.path("1", "1")) + + +def test_append_move_actions_list_of_nodes(): + """Test that nodes matched by a Selector that resolved in the same infoset + have the same actions. + """ + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F", "S"] + ) + assert game.get_actions(gbt.H.path("2", "1")) == game.get_actions(gbt.H.path("1", "1")) + + +def test_append_move_labels_list_of_nodes(): + """Test that nodes matched by a Selector that resolved in the same infoset + have the same labels per action. + """ + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F", "S"] + ) + + assert game.get_actions(gbt.H.path("2", "1")) == game.get_actions(gbt.H.path("1", "1")) + + +def test_append_move_node_list_with_non_terminal_node(): + """Test that we get an UndefinedOperationError when a Selector passed to + append_move matches a non-terminal node. + """ + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + with pytest.raises(gbt.UndefinedOperationError): + game.append_move(gbt.H.path(...), "Player 3", ["B", "F"]) + + +def test_append_move_node_list_is_empty(): + """Test that we get a ValueError when a Selector passed to append_move + matches no nodes. + """ + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + with pytest.raises(ValueError): + game.append_move(gbt.H.path(...).filter(lambda h: False), "Player 3", ["B", "F"]) + + +def test_append_infoset_node_list_with_non_terminal_node(): + """Test that we get an UndefinedOperationError when a Selector passed to + append_infoset matches a non-terminal node. + """ + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) + with pytest.raises(gbt.UndefinedOperationError): + game.append_infoset(gbt.H.path(...), gbt.H.path("1", "1")) + + +def test_append_infoset_node_list_is_empty(): + """Test that we get a ValueError when a Selector passed to append_infoset + matches no nodes. + """ + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) + with pytest.raises(ValueError): + game.append_infoset(gbt.H.path(...).filter(lambda h: False), gbt.H.path("1", "1")) + + +def test_append_infoset_error_infoset_not_a_selector(): + """Test that we get a TypeError when `infoset` is not a Selector.""" + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) + with pytest.raises(TypeError): + game.append_infoset(gbt.H.path("1", "2"), 42) + + +def test_append_infoset_error_infoset_terminal(): + """Test that we get an UndefinedOperationError when `infoset` resolves to a + terminal node.""" + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + with pytest.raises(gbt.UndefinedOperationError): + game.append_infoset(gbt.H.path("1", "2"), gbt.H.path("1", "1")) + + +def test_append_infoset_error_infoset_chance(): + """Test that we get an UndefinedOperationError when `infoset` resolves to a + chance node.""" + game = games.create_stripped_down_poker_efg() + with pytest.raises(gbt.UndefinedOperationError): + game.append_infoset(gbt.H.path("King", "Bet"), gbt.H.path()) + + +def test_append_event_creates_single_event_list_of_nodes(): + """Test that appending a Selector matching several nodes creates a single + chance event.""" + game = games.read_from_file("sample_extensive_game.efg") + matches = (("2", "1"), ("1", "1")) + game.append_event( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} + ) + assert ("2", "1") in game.get_members(gbt.H.path("1", "1")) + assert game.get_actions(gbt.H.path("2", "1")) + + +def test_append_event_sets_distribution(): + """Test that the new event's actions carry the given probabilities.""" + game = games.read_from_file("sample_extensive_game.efg") + game.append_event(gbt.H.path("1", "1"), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) + assert list(game.get_action_probs(gbt.H.path("1", "1")).values()) == [ + gbt.Rational(1, 4), gbt.Rational(3, 4) + ] + + +def test_append_event_error_actions_empty(): + """Test to ensure there are actions when appending an event.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.append_event(gbt.H.path("U1", "U2", "U3"), {}) + + +def test_append_event_error_empty_label(): + """Test that an empty label in `actions` is rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.append_event( + gbt.H.path("U1", "U2", "U3"), {"a": gbt.Rational(1, 2), "": gbt.Rational(1, 2)} + ) + + +def test_append_event_error_node_list_with_non_terminal_node(): + """Test that we get an UndefinedOperationError when a Selector passed to + append_event matches a non-terminal node. + """ + game = games.read_from_file("sample_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.append_event(gbt.H.path(...), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)}) + + +def test_append_event_error_node_list_is_empty(): + """Test that we get a ValueError when a Selector passed to append_event + matches no nodes. + """ + game = games.read_from_file("sample_extensive_game.efg") + with pytest.raises(ValueError): + game.append_event( + gbt.H.path(...).filter(lambda h: False), + {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} + ) + + +def test_append_event_error_invalid_distribution(): + """Test that a distribution which does not sum to one is rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.append_event( + gbt.H.path("U1", "U2", "U3"), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 3)} + ) + + +def test_insert_event_actions_labeled(): + """Test that the inserted event's actions are labeled according to `actions`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + game.insert_event(gbt.H.path("L", "R"), {"Up": gbt.Rational(1, 2), "Down": gbt.Rational(1, 2)}) + assert game.get_actions(gbt.H.path("L", "R")) == ["Up", "Down"] + assert game.get_player(gbt.H.path("L", "R")) == "Chance" + + +def test_insert_event_sets_distribution(): + """Test that the inserted event's actions carry the given probabilities.""" + game = games.read_from_file("basic_extensive_game.efg") + game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) + assert list(game.get_action_probs(gbt.H.path()).values()) == [ + gbt.Rational(1, 4), gbt.Rational(3, 4) + ] + + +def test_insert_event_error_actions_empty(): + """Test to ensure there are actions when inserting an event.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.insert_event(gbt.H.path(), {}) + + +def test_insert_event_error_empty_label(): + """Test that an empty label in `actions` is rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 2), "": gbt.Rational(1, 2)}) + + +def test_insert_event_error_invalid_distribution(): + """Test that a distribution which does not sum to one is rejected.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 3)}) + + +def _count_subtree_nodes(game: gbt.Game, history: tuple, count_terminal: bool) -> int: + """Counts nodes in the subtree rooted at the node with the given History + (including that node itself). + + Parameters + ---------- + game: Game + history: tuple + The History of the root of the subtree + count_terminal: bool + Include or exclude terminal nodes from count + """ + children = games.children_histories(game, history) + count = 1 if count_terminal or children else 0 + + for child in children: + count += _count_subtree_nodes(game, child, count_terminal) + return count + + +def _n_nodes(game: gbt.Game) -> int: + """The number of nodes in the game -- a stand-in for `len(Game.nodes)`, + removed since `Game.nodes` was.""" + return len(game.get_histories(gbt.H.after())) + + +def test_len_matches_expected_node_count(): + """Verify the node count matches expectations.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + expected_node_count = 9 + + direct_len = _n_nodes(game) + assert direct_len == expected_node_count + + assert direct_len == _count_subtree_nodes(game, (), True) + + +def test_len_after_delete_tree(): + """Verify the node count is correct after `delete_tree`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + initial_number_of_nodes = _n_nodes(game) + + number_of_deleted_nodes = _count_subtree_nodes(game, ("R", "L"), True) - 1 + + game.delete_tree(gbt.H.path("R", "L")) + + assert _n_nodes(game) == initial_number_of_nodes - number_of_deleted_nodes + + +def test_len_after_delete_parent(): + """Verify the node count is correct after `delete_parent`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig2") + initial_number_of_nodes = _n_nodes(game) + + node_history = ("L", "L") + + number_of_node_ancestors = _count_subtree_nodes(game, node_history, True) + number_of_parent_ancestors = _count_subtree_nodes(game, node_history[:-1], True) + diff = number_of_parent_ancestors - number_of_node_ancestors + + game.delete_parent(gbt.H.path("L", "L")) + + assert _n_nodes(game) == initial_number_of_nodes - diff + + +def test_len_after_append_move(): + """Verify the node count is correct after `append_move`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + initial_number_of_nodes = _n_nodes(game) + + player = "Player 1" + actions_to_add = ["T", "M", "B"] + + game.append_move(gbt.H.path("R", "L", "L"), player, actions_to_add) # the [1,1,0] terminal + + assert _n_nodes(game) == initial_number_of_nodes + len(actions_to_add) + + +def test_len_after_append_infoset(): + """Verify the node count is correct after `append_infoset`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig2") + initial_number_of_nodes = _n_nodes(game) + + number_of_infoset_actions = len(game.get_actions(gbt.H.path("L"))) + + game.append_infoset(gbt.H.path("L", "L", "l"), gbt.H.path("L")) + + assert _n_nodes(game) == initial_number_of_nodes + number_of_infoset_actions + + +def test_len_after_set_move_actions_add(): + """Verify the node count is correct after `set_move_actions` creates an action.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + initial_number_of_nodes = _n_nodes(game) + # "L" is Player 2's infoset + num_nodes_in_infoset = len(game.get_members(gbt.H.path("L"))) + labels = list(game.get_actions(gbt.H.path("L"))) + game.set_move_actions(gbt.H.path("L"), labels + ["new"]) + assert _n_nodes(game) == initial_number_of_nodes + num_nodes_in_infoset + + +def test_len_after_set_move_actions_drop(): + """Verify the node count is correct after `set_move_actions` deletes an action.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig2") + initial_number_of_nodes = _n_nodes(game) + action_to_drop = "L" + nodes_to_delete = sum( + _count_subtree_nodes(game, (*member, action_to_drop), True) + for member in game.get_members(gbt.H.path()) + ) + remaining = [a for a in game.get_actions(gbt.H.path()) if a != "L"] + game.set_move_actions(gbt.H.path(), remaining, drop=True) + assert _n_nodes(game) == initial_number_of_nodes - nodes_to_delete + + +def test_len_after_insert_move(): + """Verify the node count is correct after `insert_move`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + initial_number_of_nodes = _n_nodes(game) + + player = "Player 2" + actions_to_add = ["a", "b", "c"] + + game.insert_move(gbt.H.path("L", "R"), player, actions_to_add) # the [1, 0] node + + assert _n_nodes(game) == initial_number_of_nodes + len(actions_to_add) + + +def test_insert_move_actions_labeled(): + """Test that the inserted move's actions are labeled according to `actions`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + game.insert_move(gbt.H.path("L", "R"), "Player 2", ["Up", "Down"]) + assert game.get_actions(gbt.H.path("L", "R")) == ["Up", "Down"] + + +def test_len_after_insert_infoset(): + """Verify the node count is correct after `insert_infoset`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + initial_number_of_nodes = _n_nodes(game) + + number_of_infoset_actions = len(game.get_actions(gbt.H.path("L"))) + + game.insert_infoset(gbt.H.path("L", "R"), gbt.H.path("L")) + + assert _n_nodes(game) == initial_number_of_nodes + number_of_infoset_actions + + +def test_len_after_copy_tree(): + """Verify the node count is correct after `copy_tree`.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig1") + initial_number_of_nodes = _n_nodes(game) + number_of_src_ancestors = _count_subtree_nodes(game, ("R", "L"), True) + + game.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) + + assert _n_nodes(game) == initial_number_of_nodes + number_of_src_ancestors - 1 + + +def test_make_infoset_change_player_keeps_membership(): + """Re-forming an information set under a different player retains its + membership.""" + game = games.read_from_file("basic_extensive_game.efg") + _, p2, *_ = game.players + members = game.get_members(gbt.H.path()) + game.make_infoset(games.selector_for_histories(members), p2) + assert game.get_player(gbt.H.path()) == p2 + assert game.get_members(gbt.H.path()) == members + + +def test_make_infoset_terminal_node_raises(): + """All nodes must be decision nodes.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.make_infoset(gbt.H.path("U1", "U2", "U3"), game.get_player(gbt.H.path())) + + +def test_make_infoset_converts_chance_node(): + """A chance node becomes a personal decision node, discarding its probabilities.""" + game = games.read_from_file("stripped_down_poker.efg") # the deal is a chance move + personal_history = next( + h for h in game.get_histories(gbt.H.after()) + if game.get_actions(gbt.H.path(*h)) and game.get_player(gbt.H.path(*h)) != "Chance" + ) + personal_player = game.get_player(gbt.H.path(*personal_history)) + game.make_infoset(gbt.H.path(), personal_player) + assert game.get_player(gbt.H.path()) != "Chance" + assert game.get_player(gbt.H.path()) == personal_player + + +@pytest.mark.parametrize("node_actions", [["c", "d"], ["b", "a"]]) +def test_make_infoset_requires_matching_action_labels(node_actions): + """Nodes must have the same actions, with the same labels in the same order; + a matching count is not sufficient.""" + game = gbt.Game.new_tree(players=["1"]) + game.append_move(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "1", node_actions) + with pytest.raises(ValueError): + game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("a",))), "1") + + +def test_make_infoset_empty_nodes_raises(): + """`nodes` must be nonempty.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.make_infoset( + gbt.H.path(...).filter(lambda h: False), game.get_player(gbt.H.path()) + ) + + +def test_make_infoset_strategic_game_raises(): + """`make_infoset` is only defined for games with a tree representation.""" + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + game.make_infoset(gbt.H.path(), "1") + + +def test_set_move_actions_add_preserves_existing_action_order(): + """New actions may be declared at any position; the existing actions' relative + order is preserved.""" + game = games.read_from_file("basic_extensive_game.efg") + labels = list(game.get_actions(gbt.H.path())) + game.set_move_actions(gbt.H.path(), labels + ["end"]) + assert game.get_actions(gbt.H.path())[:-1] == labels + game.set_move_actions(gbt.H.path(), ["front"] + labels + ["end"]) + assert game.get_actions(gbt.H.path())[1:-1] == labels + + +@pytest.mark.parametrize( + "inprobs,outprobs", + [ + ({"King": "1/4", "Queen": "3/4"}, [gbt.Rational("1/4"), gbt.Rational("3/4")]), + ({"King": 0.75, "Queen": 0.25}, [0.75, 0.25]), + ({"King": 1}, [1, 0]), + ], +) +def test_make_event_sets_probabilities(inprobs, outprobs): + """Probabilities are given as a mapping from action label to probability, + which may be sparse: an omitted action is assigned probability zero. + """ + game = games.read_from_file("stripped_down_poker.efg") + game.make_event(gbt.H.path(), inprobs, "Deal") + probs = game.get_action_probs(gbt.H.path()) + for action, prob in zip(game.get_actions(gbt.H.path()), outprobs, strict=True): + assert probs[action] == prob + + +@pytest.mark.parametrize("probs", [["1/4", "3/4"], [0.75, 0.25]]) +def test_make_event_probs_not_a_mapping_raises_typeerror(probs): + """A positional sequence of probabilities is no longer accepted.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.make_event(gbt.H.path(), probs, "Deal") + + +def test_make_event_pools_nodes_from_different_infosets(): + """Nodes in distinct information sets are formed into a single event.""" + game = games.read_from_file("stripped_down_poker.efg") + king, queen = ("King",), ("Queen",) + game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") + assert king in game.get_members(gbt.H.path(*queen)) + assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ + gbt.Rational("1/4"), gbt.Rational("3/4") + ] + assert not game.get_infosets("Alice") + + +def test_make_event_requires_matching_action_labels(): + """Nodes must have the same actions, with the same labels in the same order.""" + game = games.read_from_file("stripped_down_poker.efg") + # King node has actions Bet, Fold; its own Bet-child has actions Call, Fold. + with pytest.raises(ValueError): + game.make_event( + gbt.H.after().filter(lambda h: h[:] in (("King",), ("King", "Bet"))), + {"Bet": "1/2", "Fold": "1/2"} + ) + + +def test_make_event_converts_personal_node(): + """A personal decision node becomes a chance node carrying the probabilities given.""" + game = games.read_from_file("stripped_down_poker.efg") + game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) + assert game.get_player(gbt.H.path("King")) == "Chance" + assert list(game.get_action_probs(gbt.H.path("King")).values()) == [ + gbt.Rational("1/4"), gbt.Rational("3/4") + ] + + +def test_make_event_terminal_node_raises(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.make_event(gbt.H.path("King", "Fold"), {"a": "1/2", "b": "1/2"}) + + +def test_make_event_strategic_game_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + game.make_event(gbt.H.path(), {"a": 1}) + + +def test_make_event_empty_nodes_raises(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.make_event(gbt.H.path(...).filter(lambda h: False), {"a": "1/2", "b": "1/2"}) + + +def test_make_event_label_held_by_rump_raises(): + """A label may be reused only if all members of the event holding it are absorbed.""" + game = games.read_from_file("stripped_down_poker.efg") + game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + before = game.to_efg() + with pytest.raises(ValueError): + game.make_event(gbt.H.path("King"), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + assert game.to_efg() == before + + +def test_make_event_label_reused_when_fully_absorbed(): + """A label held by an existing event may be reused once all of that + event's members are absorbed into the new one; the old event is not left behind. + """ + game = games.read_from_file("stripped_down_poker.efg") + king, queen = ("King",), ("Queen",) + game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") + assert king in game.get_members(gbt.H.path(*queen)) + assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ + gbt.Rational("1/4"), gbt.Rational("3/4") + ] + + +@pytest.mark.parametrize( + "probs", [{"King": "3/4", "Queen": "-1/2"}, {"King": 0.75, "Queen": 0.40}, + {"King": "foo", "Queen": "bar"}] +) +def test_make_event_invalid_probs_raises(probs): + """Values must be numbers, non-negative, and sum to exactly one.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.make_event(gbt.H.path(), probs) + + +def test_make_event_malformed_probs_raises(): + """An unknown action label as a mapping key raises KeyError.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(KeyError): + game.make_event(gbt.H.path(), {"Jack": 1}) + + +def _bagwell_p2_histories(game: gbt.Game) -> tuple[tuple, tuple, tuple, tuple]: + """Player 2's four decision node Histories in Bagwell (1995). + + Player 2 has two information sets -- one for each signal the chance move + can produce -- each with two members and actions ("S", "C"). Returns + (A, B, C, D) with {A, B} the members of one and {C, D} of the other. + """ + return (("S", "s"), ("C", "s"), ("S", "c"), ("C", "c")) + + +def test_make_infoset_cherry_pick_leaves_rumps(): + """Partial consumption leaves the remainders behind.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([B, C]), "Player 2") + assert B in game.get_members(gbt.H.path(*C)) + assert game.get_members(gbt.H.path(*A)) == [A] + assert game.get_members(gbt.H.path(*D)) == [D] + + +def test_make_infoset_label_held_by_rump_raises(): + """Reusing a label whose infoset is only partly consumed is rejected.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([A]), "Player 2", "X") + # A remains in "X" + with pytest.raises(ValueError): + game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "X") + + +def test_make_infoset_failure_leaves_game_unchanged(): + """A rejected call must not modify the partition.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([A, B]), "Player 2", "X") + game.make_infoset(games.selector_for_histories([C, D]), "Player 2", "Y") + with pytest.raises(ValueError): + game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "X") + assert A in game.get_members(gbt.H.path(*B)) + assert C in game.get_members(gbt.H.path(*D)) + + +def test_make_infoset_idempotent(): + """Repeating a call is a no-op: label reuse permits equality of membership.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "Z") + game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "Z") + assert B in game.get_members(gbt.H.path(*C)) + + +def test_make_infoset_split_creates_new_infoset(): + """A node split out of an infoset lands in a fresh infoset of its own; the rump + keeps the rest of the original membership.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(gbt.H.path("S", "s"), "Player 2") + assert game.get_members(gbt.H.path(*A)) == [A] + assert B not in game.get_members(gbt.H.path(*A)) + + +def test_make_infoset_across_different_source_players(): + """Nodes drawn from different players all land under the target player.""" + game = gbt.Game.new_tree(players=["1", "2", "3"]) + game.append_move(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "2", ["a", "b"]) # player 2 + game.append_move(gbt.H.path("b"), "3", ["a", "b"]) # player 3 + n2, n3 = ("a",), ("b",) + assert game.get_player(gbt.H.path(*n2)) == "2" + assert game.get_player(gbt.H.path(*n3)) == "3" + game.make_infoset(gbt.H.path(...), "1") + assert n2 in game.get_members(gbt.H.path(*n3)) + assert game.get_player(gbt.H.path(*n2)) == "1" + assert game.get_player(gbt.H.path(*n3)) == "1" + + +@pytest.mark.parametrize("label", games.VALID_LABELS) +def test_action_label(label: str): + game = games.create_stripped_down_poker_efg() + action = next(iter(game.get_actions(gbt.H.path()))) + game.relabel_actions(gbt.H.path(), {action: label}) + assert label in game.get_actions(gbt.H.path()) + + +@pytest.mark.parametrize("label", games.INVALID_LABELS) +def test_action_label_invalid_raises_valueerror(label: str): + game = games.create_stripped_down_poker_efg() + action = next(iter(game.get_actions(gbt.H.path()))) + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {action: label}) + + +def test_relabel_action_empty_raises_valueerror(): + game = games.create_stripped_down_poker_efg() + action = next(iter(game.get_actions(gbt.H.path()))) + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {action: ""}) + + +def test_relabel_actions_duplicate_raises_valueerror(): + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {"King": "Queen"}) + + +def test_relabel_actions_simultaneous_swap(): + """Reassignment is simultaneous, so a swap is well-defined; applying the entries one + at a time would collide on the intermediate state. + """ + game = games.create_stripped_down_poker_efg() + game.relabel_actions(gbt.H.path(), {"King": "Queen", "Queen": "King"}) + assert game.get_actions(gbt.H.path()) == ["Queen", "King"] + + +def test_relabel_actions_duplicate_targets_raises_valueerror(): + """Both replacements are free of the actions left untouched but collide with each + other, so checking each against the untouched actions alone would let this through. + """ + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": "Ace"}) + + +def test_relabel_actions_unknown_label_raises_keyerror(): + game = games.create_stripped_down_poker_efg() + with pytest.raises(KeyError): + game.relabel_actions(gbt.H.path(), {"Jack": "Ace"}) + + +def test_relabel_actions_unknown_label_not_strict_is_ignored(): + game = games.create_stripped_down_poker_efg() + game.relabel_actions(gbt.H.path(), {"Jack": "Ace", "King": "Ace"}, strict=False) + assert game.get_actions(gbt.H.path()) == ["Ace", "Queen"] + + +def test_relabel_actions_failure_leaves_game_unchanged(): + """The whole mapping is validated before any label is written, so a mapping that + fails part way through leaves no partial reassignment behind. + """ + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": ""}) + assert game.get_actions(gbt.H.path()) == ["King", "Queen"] + + +def _infoset_history(game: gbt.Game, player: str, label: str) -> tuple: + """The History of the representative member of `player`'s information set + historically identified by `label`, matching the removed `Player.infosets` + by-label lookup.""" + history = games._INFOSET_LABEL_HISTORIES[(game.title, label)] + assert game.get_player(gbt.H.path(*history)) == player + return history + + +def test_relabel_actions_scope_is_the_information_set(): + """Action labels are unique within an information set, not within a player: Alice's + two information sets both offer "Bet", and relabelling one leaves the other untouched + and free to take the same new label. + """ + game = games.create_stripped_down_poker_efg() + king = _infoset_history(game, "Alice", "Alice has King") + queen = _infoset_history(game, "Alice", "Alice has Queen") + game.relabel_actions(gbt.H.path(*king), {"Bet": "Raise"}) + assert game.get_actions(gbt.H.path(*king)) == ["Raise", "Fold"] + assert game.get_actions(gbt.H.path(*queen)) == ["Bet", "Fold"] + game.relabel_actions(gbt.H.path(*queen), {"Bet": "Raise"}) + assert game.get_actions(gbt.H.path(*queen)) == ["Raise", "Fold"] + + +def test_relabel_actions_not_a_mapping_raises_typeerror(): + game = games.create_stripped_down_poker_efg() + with pytest.raises(TypeError): + game.relabel_actions(gbt.H.path(), [("King", "Queen")]) + + +@pytest.mark.parametrize("labels", [{1: "Queen"}, {"King": 1}]) +def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): + game = games.create_stripped_down_poker_efg() + with pytest.raises(TypeError): + game.relabel_actions(gbt.H.path(), labels) + + +def test_set_move_actions_drop_shrinks_actions_and_children(): + game = games.create_stripped_down_poker_efg() + king = _infoset_history(game, "Alice", "Alice has King") + action_count = len(game.get_actions(gbt.H.path(*king))) + remaining = game.get_actions(gbt.H.path(*king))[1:] + game.set_move_actions(gbt.H.path(*king), remaining, drop=True) + assert len(game.get_actions(gbt.H.path(*king))) == action_count - 1 + assert len(games.children_histories(game, king)) == action_count - 1 + + +def test_set_move_actions_cannot_remove_the_only_action(): + game = games.create_stripped_down_poker_efg() + king = _infoset_history(game, "Alice", "Alice has King") + last = next(iter(game.get_actions(gbt.H.path(*king)))) + selector = gbt.H.path(*king) + game.set_move_actions(selector, [last], drop=True) + assert game.get_actions(gbt.H.path(*king)) == [last] + with pytest.raises(gbt.UndefinedOperationError): + game.set_move_actions(selector, [], drop=True) + + +def test_set_move_actions_reorder_carries_subtrees(): + """Reordering three actions as a cycle moves every action to a new position. + Each action carries its whole subtree with it, at every member of the information set.""" + game = gbt.Game.new_tree(players=["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Bob", ["x", "y"]) + game.append_move(gbt.H.path(...), "Alice", ["a", "b", "c"]) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in (("x", "a"), ("y", "b"))), + "Bob", ["l", "r"] + ) + members = game.get_members(gbt.H.path("x")) + children_before = [{label: (*member, label) for label in ("a", "b", "c")} + for member in members] + game.set_move_actions(gbt.H.path("x"), ["c", "a", "b"]) + assert game.get_actions(gbt.H.path("x")) == ["c", "a", "b"] + for member, children in zip(members, children_before, strict=True): + assert games.children_histories(game, member) == [ + children["c"], children["a"], children["b"] + ] + + +def test_set_move_actions_add_drop_and_reorder_together(): + game = games.create_stripped_down_poker_efg() + king = _infoset_history(game, "Alice", "Alice has King") + nodes_before = _n_nodes(game) + game.set_move_actions(gbt.H.path(*king), ["Raise", "Fold"], drop=True) + assert game.get_actions(gbt.H.path(*king)) == ["Raise", "Fold"] + # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. + assert _n_nodes(game) == nodes_before - 3 + 1 + # Bob's response infoset survives, now with only its Queen-side member: the + # King-side member (found via the removed "Bet" action) is gone with the subtree. + assert len(game.get_members(gbt.H.path("Queen", "Bet"))) == 1 + + +def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): + game = games.create_stripped_down_poker_efg() + king = _infoset_history(game, "Alice", "Alice has King") + before = game.to_efg() + selector = gbt.H.path(*king) + with pytest.raises(ValueError): + game.set_move_actions(selector, ["Bet"]) + with pytest.raises(ValueError): + game.set_move_actions(selector, ["Bet", "Fold", "Raise"], add=False) + assert game.to_efg() == before + + +def test_set_move_actions_raises_at_an_event(): + """`set_move_actions` is only for a personal player's move; `set_event_actions` is the + corresponding operation for an event.""" + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.set_move_actions(gbt.H.path(), ["King", "Queen"]) + + +@pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) +def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): + """Duplicate, empty, and invalid labels in `actions` are rejected in C++, + after the Python guards pass; the game must be unmodified by the failure.""" + game = games.create_stripped_down_poker_efg() + king = _infoset_history(game, "Alice", "Alice has King") + before = game.to_efg() + with pytest.raises(ValueError): + game.set_move_actions(gbt.H.path(*king), bad_labels, drop=True) + assert game.to_efg() == before + + +def test_set_move_actions_absent_minded_drop_and_add(): + """Dropping an action whose subtree contains another member of the same information + set deletes that member with the subtree.""" + game = gbt.Game.new_tree(players=["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) + game.append_infoset(gbt.H.path("a"), gbt.H.path()) + game.set_move_actions(gbt.H.path(), ["b", "c"], drop=True) + assert game.get_actions(gbt.H.path()) == ["b", "c"] + assert len(game.get_members(gbt.H.path())) == 1 + assert _n_nodes(game) == 3 + + +def test_set_event_actions_reorder_carries_probabilities(): + game = games.create_stripped_down_poker_efg() + game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "1/4"}) + game.set_event_actions(gbt.H.path(), {"Queen": "1/4", "King": "3/4"}) + assert game.get_actions(gbt.H.path()) == ["Queen", "King"] + assert game.get_action_probs(gbt.H.path()) == { + "Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4) + } + + +def test_set_event_actions_add_with_probs_mapping(): + game = games.create_stripped_down_poker_efg() + nodes_before = _n_nodes(game) + game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) + assert game.get_actions(gbt.H.path()) == ["Jack", "King", "Queen"] + assert game.get_action_probs(gbt.H.path()) == { + "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) + } + assert _n_nodes(game) == nodes_before + 1 + + +def test_set_event_actions_drop_with_probs_mapping(): + game = games.create_stripped_down_poker_efg() + game.set_event_actions(gbt.H.path(), {"King": 1}, drop=True) + assert game.get_actions(gbt.H.path()) == ["King"] + assert game.get_action_probs(gbt.H.path()) == {"King": 1} + + +def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): + game = games.create_stripped_down_poker_efg() + before = game.to_efg() + with pytest.raises(ValueError): + game.set_event_actions(gbt.H.path(), {"King": 1}) + with pytest.raises(ValueError): + game.set_event_actions( + gbt.H.path(), {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False + ) + assert game.to_efg() == before + + +def test_set_event_actions_raises_at_a_move(): + """`set_event_actions` is only for an event; `set_move_actions` is the corresponding + operation for a personal player's move.""" + game = games.create_stripped_down_poker_efg() + king = _infoset_history(game, "Alice", "Alice has King") + with pytest.raises(ValueError): + game.set_event_actions(gbt.H.path(*king), {"Bet": 1}) + + +def test_set_event_actions_rejects_non_mapping_probs(): + """`probs` must be a mapping: with no separate list of actions, there's nothing for a + plain sequence of probabilities to be paired with positionally.""" + game = games.create_stripped_down_poker_efg() + before = game.to_efg() + with pytest.raises(TypeError): + game.set_event_actions(gbt.H.path(), ["3/4", "1/4"]) + assert game.to_efg() == before + + +def test_set_event_actions_bad_distribution_raises_valueerror(): + game = games.create_stripped_down_poker_efg() + before = game.to_efg() + with pytest.raises(ValueError): + game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "3/4"}) + assert game.to_efg() == before diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py new file mode 100644 index 0000000000..f160988c28 --- /dev/null +++ b/tests/test_tree_queries.py @@ -0,0 +1,533 @@ +import dataclasses +import functools +import typing + +import pytest + +import pygambit as gbt + +from . import games + + +def test_get_outcome(): + """A tree game's `get_outcome` resolves a `Selector` to a single node and + returns the label of the outcome attached there, or `None` if it has none.""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.get_outcome(gbt.H.path("U1", "D2", "U3")) == "Outcome 1" + assert game.get_outcome(gbt.H.path()) is None + + +def test_get_outcome_requires_selector(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(TypeError): + game.get_outcome(()) + with pytest.raises(TypeError): + game.get_outcome("U1") + + +def test_get_outcome_requires_single_match(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.get_outcome(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_outcome(gbt.H.path(...).filter(lambda h: False)) + + +def test_get_actions(): + """A personal or chance node's actions, in order; a terminal node has none -- + a node is terminal exactly when this is empty.""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.get_actions(gbt.H.path()) == ["U1", "D1"] + assert game.get_actions(gbt.H.path("U1")) == ["U2", "D2"] + assert game.get_actions(gbt.H.path("U1", "D2", "U3")) == [] + + +def test_get_actions_requires_selector(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(TypeError): + game.get_actions(()) + with pytest.raises(TypeError): + game.get_actions("U1") + + +def test_get_actions_requires_single_match(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.get_actions(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_actions(gbt.H.path(...).filter(lambda h: False)) + + +def test_get_action_probs(): + """A chance node's action probabilities, keyed by label; empty for a + personal node or a terminal node -- a node currently belongs to a chance + event exactly when this is nonempty.""" + game = games.read_from_file("stripped_down_poker.efg") + assert game.get_action_probs(gbt.H.path()) == { + "King": gbt.Rational(1, 2), "Queen": gbt.Rational(1, 2) + } + assert game.get_action_probs(gbt.H.path("King")) == {} + assert game.get_action_probs(gbt.H.path("King", "Fold")) == {} + + +def test_get_action_probs_requires_selector(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.get_action_probs(()) + with pytest.raises(TypeError): + game.get_action_probs("King") + + +def test_get_action_probs_requires_single_match(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.get_action_probs(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_action_probs(gbt.H.path(...).filter(lambda h: False)) + + +def test_get_members(): + """The Histories of the nodes sharing a node's current information set or + event, including a shared infoset across the members of different chance + outcomes; empty for a terminal node.""" + game = games.read_from_file("stripped_down_poker.efg") + assert game.get_members(gbt.H.path()) == [()] + assert game.get_members(gbt.H.path("King", "Bet")) == [("King", "Bet"), ("Queen", "Bet")] + assert game.get_members(gbt.H.path("King", "Fold")) == [] + + +def test_get_members_requires_selector(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.get_members(()) + with pytest.raises(TypeError): + game.get_members("King") + + +def test_get_members_requires_single_match(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.get_members(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_members(gbt.H.path(...).filter(lambda h: False)) + + +def test_get_player(): + """A personal node's player; a terminal node has none.""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.get_player(gbt.H.path()) == "Player 1" + assert game.get_player(gbt.H.path("U1", "D2", "U3")) is None + + +def test_get_player_resolves_chance(): + """At a chance node, the player label is the chance player's.""" + game = games.read_from_file("stripped_down_poker.efg") + assert game.get_player(gbt.H.path()) == "Chance" + + +def test_get_player_requires_selector(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(TypeError): + game.get_player(()) + with pytest.raises(TypeError): + game.get_player("U1") + + +def test_get_player_requires_single_match(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.get_player(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_player(gbt.H.path(...).filter(lambda h: False)) + + +def _get_path_of_action_labels(node: gbt.Node) -> list[str]: + """ + Computes the path of action labels from a given node to the root. + Returns a list of strings. + """ + if not isinstance(node, gbt.Node): + raise TypeError(f"Input must be a pygambit.Node, but got {type(node).__name__}") + + path = [] + current_node = node + while current_node._parent(): + path.append(current_node._prior_action().label) + current_node = current_node._parent() + + return path + + +@dataclasses.dataclass +class SubgameRootsTestCase: + """TestCase for testing subgame root detection.""" + factory: typing.Callable[[], gbt.Game] + expected_paths: list[list[str]] + + +SUBGAME_ROOTS_CASES = [ + # ------------------------------------------------------------------------ + # Empty Game + # ------------------------------------------------------------------------ + pytest.param( + # `GetSubgames()` returns no roots at all for a single-node (terminal-root) game, + # unlike `IsSubgameRoot()` (which special-cases it as trivially its own subgame) -- + # a known, narrow C++-core discrepancy (`GameTreeRep::GetSubgameData()`'s early + # return for `m_root->IsTerminal()`), not something to paper over here. + SubgameRootsTestCase(factory=gbt.Game.new_tree, expected_paths=[]), + id="empty_tree" + ), + # ------------------------------------------------------------------------ + # Perfect Information Games + # ------------------------------------------------------------------------ + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(gbt.catalog.load, "journals/ijgt/selten1975/fig2"), + expected_paths=[[], ["L"], ["L", "L"]] + ), + id="centipede_3_rounds" + ), + pytest.param( + SubgameRootsTestCase( + factory=lambda: games.Centipede.get_test_data(N=5, m0=2, m1=7)[0], + expected_paths=[[], ["Push"], ["Push", "Push"], ["Push", "Push", "Push"], + ["Push", "Push", "Push", "Push"]] + ), + id="centipede_5_rounds" + ), + # ------------------------------------------------------------------------ + # Imperfect Information (No Absent-Mindedness) + # ------------------------------------------------------------------------ + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), + expected_paths=[[]] + ), + id="wichardt_no_nontrivial_subgames" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "binary_3_levels_generic_payoffs.efg"), + expected_paths=[[]] + ), + id="binary_3_levels_no_nontrivial_subgames" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial( + games.read_from_file, + "subgame_roots_finder_small_subgames_and_overplapping_infosets.efg"), + expected_paths=[[], ["1"], ["2"], ["1", "2", "2"], ["2", "1", "2"], + ["1", "1", "1", "2", "2"], ["2", "2", "2"]] + ), + id="small_subgames_and_overlapping_infosets_inside_subgames_no_Nature_moves" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial( + games.read_from_file, + "subgame_roots_finder_overplapping_infosets_with_Nature.efg"), + expected_paths=[[], ["1_2"], ["1_2", "1_3", "1_2"], ["1_3", "1_2"]] + ), + id="overlapping_infosets_inside_subgames_and_Nature_move" + ), + # ------------------------------------------------------------------------ + # Absent-Minded Games + # ------------------------------------------------------------------------ + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-subgames.efg"), + expected_paths=[[], ["2"], ["1", "1"], ["2", "1"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "noPR-action-AM-two-hops.efg"), + expected_paths=[[], ["2", "1", "1"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-three-times" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-unary-hops.efg"), + expected_paths=[[], ["1", "1"], ["T", "1", "1", "1", "1", "1"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-unary-branches.efg"), + expected_paths=[[], ["1", "1", "1", "T"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), +] + + +@pytest.mark.parametrize("test_case", SUBGAME_ROOTS_CASES) +def test_subgame_roots(test_case: SubgameRootsTestCase): + """ + Tests that `Game.get_subgame_roots` matches the expected set of paths + (Action Labels from Node -> Root, matching `_get_path_of_action_labels`'s + convention -- `get_subgame_roots` itself returns Histories, Root -> Node). + """ + game = test_case.factory() + + actual_paths = [list(reversed(history)) for history in game.get_subgame_roots()] + + assert sorted(actual_paths) == sorted(test_case.expected_paths) + + +# ============================================================================ +# Subgames +# ============================================================================ +@dataclasses.dataclass +class SubgameStructureTestCase: + """Expected subgame structure of a game. + + `roots` lists the History of each subgame root, in the postorder + `Game.get_subgame_roots` is expected to produce (children before parents). + + `differences` maps each subgame-root History to the set of + (player_label, infoset_number) keys in that subgame's difference --- + the information sets belonging to the subgame but not to any child subgame. + """ + factory: typing.Callable[[], gbt.Game] + roots: list[tuple[str, ...]] + differences: dict[tuple[str, ...], set[tuple[str, int]]] + + +SUBGAME_STRUCTURE_CASES = [ + # ------------------------------------------------------------------------ + # EF game with the only subgame + # ------------------------------------------------------------------------ + pytest.param( + SubgameStructureTestCase( + factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), + roots=[()], + differences={(): {("Player 1", 0), ("Player 1", 1), ("Player 2", 0)}}, + ), + id="wichardt_no_nontrivial_subgames", + ), + # ------------------------------------------------------------------------ + # Tree with eight subgames + # ------------------------------------------------------------------------ + pytest.param( + SubgameStructureTestCase( + factory=functools.partial(games.read_from_file, "subgame-8-roots.efg"), + roots=[ + ("L", "L", "L", "L", "L"), + ("L", "L", "L", "L", "R"), + ("L", "L", "L", "L"), + ("L", "L"), + ("L", "R"), + ("L",), + ("R",), + (), + ], + differences={ + ("L", "L", "L", "L", "L"): { + ("Player 1", 3), ("Player 2", 2), ("Player 2", 3), + }, + ("L", "L", "L", "L", "R"): {("Player 1", 4), ("Player 1", 5)}, + ("L", "L", "L", "L"): {("Player 2", 1)}, + ("L", "L"): {("Player 1", 1), ("Player 1", 2)}, + ("L", "R"): {("Player 1", 6)}, + ("L",): {("Player 2", 0)}, + ("R",): { + ("Player 1", 7), ("Player 1", 8), ("Player 1", 9), + ("Player 2", 4), ("Player 2", 5), ("Player 2", 6), + }, + (): {("Player 1", 0)}, + }, + ), + id="eight_subgames", + ), +] + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_get_subgame_roots_postorder_sequence(test_case: SubgameStructureTestCase): + """`Game.get_subgame_roots` produces the expected postorder sequence of + subgame-root Histories (children before parents).""" + game = test_case.factory() + assert game.get_subgame_roots() == test_case.roots + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): + """`game.get_minimal_subgame(history)` returns the History of the root of the + smallest subgame containing the information set `history` belongs to.""" + game = test_case.factory() + expected_root_for_key = { + key: root + for root, keys in test_case.differences.items() + for key in keys + } + for player in game.players: + for i, history in enumerate(game.get_infosets(player)): + key = (player, i) + selector = gbt.H.path(*history) + assert game.get_minimal_subgame(selector) == expected_root_for_key[key] + + +@pytest.mark.parametrize("game_file, expected_unreachable_paths", [ + # Games without absent-mindedness, where all nodes are reachable + (gbt.catalog.load("journals/geb/wichardt2008"), []), + ("subgames.efg", []), + + # An absent-minded driver game with an unreachable terminal node + ( + "AM-driver-one-infoset.efg", + [["T", "S"]] + ), + + # An absent-minded driver game with an unreachable subtree + ( + "AM-driver-subgame.efg", + [["T", "S"], ["r", "T", "S"], ["l", "T", "S"]] + ), +]) +def test_get_strategy_unreachable(game_file: str, expected_unreachable_paths: list[list[str]]): + """ + Tests `Game.get_strategy_unreachable` against a known-correct list of + unreachable-node paths (Action Labels from Node -> Root, matching + `_get_path_of_action_labels`'s convention -- `get_strategy_unreachable` + itself returns Histories, Root -> Node). + """ + game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) + + actual_unreachable_paths = [ + list(reversed(history)) for history in game.get_strategy_unreachable() + ] + + assert actual_unreachable_paths == expected_unreachable_paths + + +@pytest.mark.parametrize( + "game, player_label, strategy_label, infoset_path, expected_action_label", + [ + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "1", [], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "2", [], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "1", ["R"], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "2", ["R"], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "1", ["R", "L"], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "2", ["R", "L"], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", [], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "2", [], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "1", ["L"], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "2", ["L"], "L"), + (games.read_from_file("basic_extensive_game.efg"), "Player 1", "1", [], "U1"), + (games.read_from_file("basic_extensive_game.efg"), "Player 1", "2", [], "D1"), + (games.read_from_file("basic_extensive_game.efg"), "Player 2", "1", ["U1"], "U2"), + (games.read_from_file("basic_extensive_game.efg"), "Player 2", "2", ["U1"], "D2"), + (games.read_from_file("basic_extensive_game.efg"), "Player 3", "1", ["U1", "U2"], "U3"), + (games.read_from_file("basic_extensive_game.efg"), "Player 3", "2", ["U1", "U2"], "D3"), + ], +) +def test_get_behavior_prescribed_action_defined( + game, player_label, strategy_label, infoset_path, expected_action_label +): + """Verify `Game.get_behavior` retrieves the correct action for defined actions.""" + selector = gbt.H.path(*infoset_path) + + prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) + + assert prescribed_action == expected_action_label + + +@pytest.mark.parametrize( + "game, player_label, strategy_label, infoset_label, infoset_path", + [ + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", None, ["L", "L"]), + (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,3)", None), + (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,5)", None), + (games.read_from_file("cent3.efg"), "Player 1", "2", "(1,5)", None), + (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), + (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), + (games.read_from_file("cent3.efg"), "Player 2", "2", "(2,5)", None), + ], +) +def test_get_behavior_prescribed_action_undefined_returns_none( + game, player_label, strategy_label, infoset_label, infoset_path +): + """Verify `Game.get_behavior` returns None when called on an unreached player's infoset""" + if infoset_label is not None: + node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) + selector = games.selector_for_node(node) + else: + selector = gbt.H.path(*infoset_path) + + prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) + + assert prescribed_action is None + + +@pytest.mark.parametrize( + "game, player_label, other_infoset_path", + [ + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", ["R"]), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", []), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", ["L"]), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", []), + (games.read_from_file("basic_extensive_game.efg"), "Player 1", ["U1"]), + (games.read_from_file("basic_extensive_game.efg"), "Player 2", ["U1", "U2"]), + (games.read_from_file("basic_extensive_game.efg"), "Player 3", []), + ], +) +def test_get_behavior_raises_value_error_for_wrong_player( + game, player_label, other_infoset_path +): + """ + Verify `Game.get_behavior`'s result raises ValueError when the infoset belongs + to a different player than the strategy. + """ + behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) + other_selector = gbt.H.path(*other_infoset_path) + + with pytest.raises(ValueError): + behavior.get(other_selector) + + +@pytest.mark.parametrize( + "game_obj", + [ + pytest.param(games.read_from_file("basic_extensive_game.efg")), + pytest.param(games.read_from_file("binary_3_levels_generic_payoffs.efg")), + pytest.param(games.read_from_file("cent3.efg")), + pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig1")), + pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2")), + pytest.param(games.read_from_file("stripped_down_poker.efg")), + pytest.param(gbt.Game.new_tree()), + ], +) +def test_get_histories_after_iteration_order(game_obj: gbt.Game): + """`Game.get_histories(H.after())` -- the public replacement for the removed + `Game.nodes` -- produces nodes in depth-first traversal order. + """ + def dfs(history: tuple) -> typing.Iterator[tuple]: + yield history + for action in game_obj.get_actions(gbt.H.path(*history)): + yield from dfs((*history, action)) + + expected = list(dfs(())) + assert game_obj.get_histories(gbt.H.after()) == expected + + +def test_layout_tree(): + """`layout_tree` returns a `TreeLayout`, keyed by History, with one + `TreeLayoutCoordinates` entry per node of the game.""" + game = games.read_from_file("basic_extensive_game.efg") + layout = gbt.layout_tree(game) + + assert len(layout) == len(game.get_histories(gbt.H.after())) + for history in game.get_histories(gbt.H.after()): + assert history in layout + coordinates = layout[history] + assert isinstance(coordinates, gbt.TreeLayoutCoordinates) + assert isinstance(coordinates.level, int) + assert isinstance(coordinates.sublevel, int) + assert isinstance(coordinates.offset, float) + + assert set(layout) == set(game.get_histories(gbt.H.after())) From 239df8a24effbb1904a05670ff9b782620d5423e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Fern=C3=A1ndez=20Cervell?= <153729439+AndresFerCervell@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:32:01 +0200 Subject: [PATCH 11/11] HP provisional termination (#1121) Set up termination condition for HP per our best ideas so far; wire up maxRegret properly --- doc/algorithms.rst | 36 ++++++ doc/tools.hp.rst | 5 + doc/tutorials/03_stripped_down_poker.ipynb | 24 +--- src/gui/dlnash.cc | 6 +- src/gui/nashspec.cc | 2 +- src/gui/nashspec.h | 1 + src/pygambit/cli/hp.py | 11 +- src/pygambit/gambit.pxd | 2 +- src/pygambit/nash.h | 5 +- src/pygambit/nash.pxi | 3 +- src/pygambit/nash.py | 12 +- src/solvers/hp/hp.cc | 65 +++++++++- src/solvers/hp/hp.h | 2 +- src/solvers/path/path.cc | 137 ++++++++++++++++++--- src/solvers/path/path.h | 16 +++ 15 files changed, 269 insertions(+), 58 deletions(-) diff --git a/doc/algorithms.rst b/doc/algorithms.rst index 317306b029..db71bc87d2 100644 --- a/doc/algorithms.rst +++ b/doc/algorithms.rst @@ -22,6 +22,42 @@ Algorithm Description :ref:`hp` Compute a specific Nash equilibrium using a homotopy path-following method :py:func:`pygambit.nash.hp_solve` :ref:`gambit-hp ` ================ =========================================================================== ======================================== ========================================== +.. _pygambit-nash-maxregret: + +Acceptance criteria for approximate Nash equilibria +---------------------------------------------------- + +Some methods for computing Nash equilibria operate using floating-point arithmetic and/or +generate candidate equilibrium profiles using methods which involve some form of successive +approximation. The outputs of these methods therefore are in general +:math:`\varepsilon`-equilibria, for some positive :math:`\varepsilon`: a strategy profile at +which no player can gain more than :math:`\varepsilon` in expected payoff by unilaterally +deviating. Every Nash equilibrium is an :math:`\varepsilon`-equilibrium with +:math:`\varepsilon = 0`. + +To provide a uniform interface across methods, where relevant Gambit provides a parameter +`maxregret`, which specifies the acceptance criterion for labeling the output of the +algorithm as an equilibrium. This parameter is interpreted *proportionally* to the range of +payoffs in the game: any profile returned as an equilibrium is guaranteed to be an +:math:`\varepsilon`-equilibrium, for :math:`\varepsilon` no more than `maxregret` times the +difference of the game's maximum and minimum payoffs. For example, with the default +`maxregret` of :math:`10^{-8}` in a game whose payoffs range over 4 units, any equilibrium +returned is guaranteed to have a regret, measured directly in the game's own payoffs, of no +more than :math:`4 \times 10^{-8}`. + +Expressing `maxregret` scaled by the game's payoffs in this way standardises the behavior of +methods across games: for instance, doubling all the payoffs in a game does not change the +`maxregret` value needed to obtain equilibria of comparable quality. + +Methods differ in the guarantees they offer once a `maxregret` criterion is specified. +Globally-convergent methods, such as :ref:`logit` and :ref:`gnm`, are guaranteed to eventually +satisfy any `maxregret` criterion, though a tighter criterion generally requires more +computation. Other methods, such as :ref:`liap`, are not globally convergent, and may fail to +find any equilibrium satisfying the criterion from a given starting point. + +See the :doc:`stripped-down poker tutorial ` for a worked +example comparing `maxregret` across several methods. + .. _enumpure: enumpure diff --git a/doc/tools.hp.rst b/doc/tools.hp.rst index b2eac367c4..86770fa328 100644 --- a/doc/tools.hp.rst +++ b/doc/tools.hp.rst @@ -24,6 +24,11 @@ different equilibria being found. Prints a help message listing the available options. +.. cmdoption:: -m + + Specify the maximum regret criterion for acceptance as an approximate Nash equilibrium + (default is 1e-8). See :ref:`pygambit-nash-maxregret` for interpretation and guidance. + .. cmdoption:: -n Randomly generate the specified number of prior distributions. diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index fab307ac26..cc890eec5a 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -675,29 +675,7 @@ "cell_type": "markdown", "id": "b2867dca", "metadata": {}, - "source": [ - "Acceptance criteria for Nash equilibria\n", - "---------------------------------------\n", - "\n", - "Some methods for computing Nash equilibria operate using floating-point arithmetic and/or generate candidate equilibrium profiles using methods which involve some form of successive approximations.\n", - "The outputs of these methods therefore are in general $\\varepsilon$-equilibria, for some positive $\\varepsilon$.\n", - "\n", - "$\\varepsilon$-equilibria (from [Wikipedia](https://en.wikipedia.org/wiki/Epsilon-equilibrium)):\n", - "\n", - "> In game theory, an epsilon-equilibrium, or near-Nash equilibrium, is a strategy profile that approximately satisfies the condition of Nash equilibrium. In a Nash equilibrium, no player has an incentive to change his behavior. In an approximate Nash equilibrium, this requirement is weakened to allow the possibility that a player may have a small incentive to do something different.\n", - "\n", - "> Given a game and a real non-negative parameter $\\varepsilon$, a strategy profile is said to be an $\\varepsilon$-equilibrium if it is not possible for any player to gain more than $\\varepsilon$ in expected payoff by unilaterally deviating from his strategy. Every Nash Equilibrium is an $\\varepsilon$-equilibrium where $\\varepsilon = 0$.\n", - "\n", - "\n", - "To provide a uniform interface across methods, where relevant Gambit provides a parameter\n", - "`maxregret`, which specifies the acceptance criterion for labeling the output of the\n", - "algorithm as an equilibrium.\n", - "This parameter is interpreted *proportionally* to the range of payoffs in the game.\n", - "Any profile returned as an equilibrium is guaranteed to be an $\\varepsilon$-equilibrium, for $\\varepsilon$ no more than `maxregret`\n", - "times the difference of the game's maximum and minimum payoffs.\n", - "\n", - "As an example, consider solving our one-card poker game using `logit_solve`. The range of the payoffs in this game is 4 (from +2 to -2):\n" - ] + "source": "Acceptance criteria for Nash equilibria\n---------------------------------------\n\nSome methods for computing Nash equilibria operate using floating-point arithmetic and/or generate candidate equilibrium profiles using methods which involve some form of successive approximations, and so return only approximate equilibria.\nGambit expresses how close an approximation must be, before it is reported as an equilibrium, via a `maxregret` parameter, interpreted *proportionally* to the range of payoffs in the game.\nSee [Acceptance criteria for approximate Nash equilibria](../algorithms.html#pygambit-nash-maxregret) in the algorithms documentation for the full explanation of `maxregret` and the guarantees it provides.\n\nAs an example, consider solving our one-card poker game using `logit_solve`. The range of the payoffs in this game is 4 (from +2 to -2):\n" }, { "cell_type": "code", diff --git a/src/gui/dlnash.cc b/src/gui/dlnash.cc index 702b3b63d2..5572e7b1f8 100644 --- a/src/gui/dlnash.cc +++ b/src/gui/dlnash.cc @@ -175,7 +175,8 @@ wxString ExternalCommand(const NashComputationSpec &p_spec) method.localNewtonMaxIterations); } else if constexpr (std::is_same_v) { - return prefix + wxString::Format("hp -d 10 -n %d", method.priors); + return prefix + + wxString::Format("hp -d 10 -n %d -m %.17g", method.priors, method.maxRegret); } else if constexpr (std::is_same_v) { return prefix + wxString::Format("ipa -d 10 -n %d", method.perturbations); @@ -274,7 +275,8 @@ wxString ParameterDescription(const NashMethodSpec &p_method) method.localNewtonMaxIterations); } else if constexpr (std::is_same_v) { - return wxString::Format(" (%d random priors)", method.priors); + return wxString::Format(" (%d random priors; maximum regret %.4g)", method.priors, + method.maxRegret); } else if constexpr (std::is_same_v) { return wxString::Format(" (%d perturbation)", method.perturbations); diff --git a/src/gui/nashspec.cc b/src/gui/nashspec.cc index 77e5d17f31..54be81996f 100644 --- a/src/gui/nashspec.cc +++ b/src/gui/nashspec.cc @@ -104,7 +104,7 @@ std::optional HPNashSpec::MakeSolver(NashRepresentation) const for (const auto &prior : NewRandomStrategyProfiles(p_game, spec.priors)) { p_cancel.Check(); Nash::HPStrategySolve( - prior, + prior, spec.maxRegret, [&p_callback](const MixedStrategyProfile &p) { p_callback(ComputedProfile(p)); }, Nash::NullHPEventCallback, p_cancel); } diff --git a/src/gui/nashspec.h b/src/gui/nashspec.h index de07b4d933..d138cf9f8b 100644 --- a/src/gui/nashspec.h +++ b/src/gui/nashspec.h @@ -102,6 +102,7 @@ struct HPNashSpec { // path-tracing can surface more), so this defaults high, like LiapNashSpec's // startingPoints, rather than to 1. int priors{10}; + double maxRegret{1.0e-8}; std::optional MakeSolver(NashRepresentation) const; }; diff --git a/src/pygambit/cli/hp.py b/src/pygambit/cli/hp.py index 0daa664b61..41acc7890c 100644 --- a/src/pygambit/cli/hp.py +++ b/src/pygambit/cli/hp.py @@ -80,6 +80,14 @@ default=None, help="file containing prior distributions (mutually exclusive with -n)", ) +@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("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") @click.option( "-V", @@ -95,6 +103,7 @@ def main( n_priors: int | None, seed: int | None, start_file: str | None, + maxregret: float, quiet: bool, verbose: bool, ) -> None: @@ -109,7 +118,7 @@ def render_event(event: gbt.HPStepEvent) -> None: prior = prior.as_float() if verbose: click.echo(render_profile_csv(prior, "prior", decimals)) - result = gbt.nash.hp_solve(prior, event_callback=render_event) + result = gbt.nash.hp_solve(prior, maxregret=maxregret, event_callback=render_event) for eq in result.equilibria: click.echo(render_profile_csv(eq, "NE", decimals)) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 8dce490284..1c8c16f73f 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -701,5 +701,5 @@ cdef extern from "nash.h": LogitEventCallbackType[c_LogitQREMixedStrategyProfile] ) except + stdlist[c_MixedStrategyProfile[double]] HPStrategySolveWrapper( - c_MixedStrategyProfile[double], HPEventCallbackType + c_MixedStrategyProfile[double], double, HPEventCallbackType ) except +RuntimeError diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 75dbfb2e20..815a27d786 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -143,8 +143,9 @@ LogitStrategyEstimateWrapper(std::shared_ptr> p_fre } std::list> -HPStrategySolveWrapper(const MixedStrategyProfile &p_prior, +HPStrategySolveWrapper(const MixedStrategyProfile &p_prior, double p_maxRegret, Nash::HPEventCallbackType p_onEvent = Nash::NullHPEventCallback) { - return Nash::HPStrategySolve(p_prior, Nash::NullStrategyCallback, p_onEvent); + return Nash::HPStrategySolve(p_prior, p_maxRegret, Nash::NullStrategyCallback, + p_onEvent); } diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index e319846a3e..595a422235 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -914,8 +914,9 @@ def _logit_behavior_branch(game: Game, def _hp_strategy_solve( prior: MixedStrategyProfileDouble, + maxregret: float, event_callback: object = None, ) -> list[MixedStrategyProfileDouble]: return _convert_mspd(HPStrategySolveWrapper( - deref(prior.profile), MakeHPEventCallback(event_callback) + deref(prior.profile), maxregret, MakeHPEventCallback(event_callback) )) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 32aa3aa07c..8789079022 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -1113,6 +1113,7 @@ def logit_solve( def hp_solve( prior: libgbt.MixedStrategyProfileDouble, + maxregret: float = 1.0e-8, event_callback: Callable[[libgbt.HPStepEvent], None] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a game using :cite:p:`HerPee01` @@ -1125,6 +1126,11 @@ def hp_solve( prior : MixedStrategyProfileDouble The prior distribution over strategies. + maxregret : float, default 1e-8 + The acceptance criterion for approximate Nash equilibrium; the maximum + regret of any player must be no more than `maxregret` times the + difference of the maximum and minimum payoffs of the game + event_callback : Callable[[HPStepEvent], None], optional If specified, called with each point traced along the homotopy path, and the homotopy parameter ``t`` at which it was reached, on the way @@ -1137,12 +1143,14 @@ def hp_solve( res : NashComputationResult The result represented as a ``NashComputationResult`` object. """ - equilibria = libgbt._hp_strategy_solve(prior, event_callback) + if maxregret <= 0.0: + raise ValueError("hp_solve(): maxregret argument must be positive") + equilibria = libgbt._hp_strategy_solve(prior, maxregret, event_callback) return NashComputationResult( game=prior.game, method="hp", rational=False, use_strategic=True, equilibria=equilibria, - parameters={}, + parameters={"maxregret": maxregret}, ) diff --git a/src/solvers/hp/hp.cc b/src/solvers/hp/hp.cc index 25b06325c2..95e640b875 100644 --- a/src/solvers/hp/hp.cc +++ b/src/solvers/hp/hp.cc @@ -27,7 +27,7 @@ namespace Gambit::Nash { std::list> -HPStrategySolve(const MixedStrategyProfile &p_prior, +HPStrategySolve(const MixedStrategyProfile &p_prior, double p_maxRegret, StrategyCallbackType p_onEquilibrium, HPEventCallbackType p_onEvent, const CancelToken &p_cancel) { @@ -39,11 +39,49 @@ HPStrategySolve(const MixedStrategyProfile &p_prior, const PathTracer tracer; const PathTracer::TraceDirection direction = PathTracer::TraceDirection::Positive; const size_t tracking_index = 1; // Track the first variable (t) for orientation - auto termination_condition = [](const Vector &point) { return point[1] >= 1.5; }; - auto criterion_function = [](const Vector &point, - const Vector &tangent) -> double { return point[1] - 1.0; }; + const double t_target = 1.0; + double last_t = 0.0; + bool has_crossed = false; - tracer.TracePath( + auto termination_condition = [t_target, &last_t, &has_crossed, p_maxRegret, + &system](const Vector &point) { + const double t = point[1]; + + // Path tracer reaches maximum acceptable regret + if (system.ExtractEquilibrium(point).GetMaxRegret() <= p_maxRegret && + t >= t_target - p_maxRegret) { + return true; + } + + if (t > t_target) { + if (!has_crossed) { + has_crossed = true; + } + else if (t > + last_t + p_maxRegret) { // Criterion function is not working; polish will do the job + return true; + } + } + else { + // Criterion function might take t back to being less than t_target + has_crossed = false; + } + + last_t = t; + return false; + }; + + auto criterion_function = [t_target](const Vector &point, + const Vector &tangent) -> double { + return point[1] - t_target; + }; + + auto polishing_termination_condition = [p_maxRegret, + &system](const Vector &point) -> bool { + return system.ExtractEquilibrium(point).GetMaxRegret() <= p_maxRegret; + }; + + const auto tracing_result = tracer.TracePath( [&system](const Vector &point, Vector &lhs) { system.GetValue(point, lhs); }, [&system](const Vector &point, Matrix &jac) { system.GetJacobian(point, jac); @@ -55,7 +93,24 @@ HPStrategySolve(const MixedStrategyProfile &p_prior, }, criterion_function, NullCriterionBracketFunction, p_cancel); + const PolishResult polishing_result = PolishPoint( + [&system](const Vector &point, Vector &lhs) { system.GetValue(point, lhs); }, + [&system](const Vector &point, Matrix &jac) { + system.GetJacobian(point, jac); + }, + x, t_target, 1, polishing_termination_condition, 100, + [&system, &p_onEvent](const Vector &point) { + const MixedStrategyProfile profile = system.ExtractEquilibrium(point); + p_onEvent(HPStepEvent{.profile = profile, .t = point[1]}); + }); + + if (!polishing_result.status) { + return {}; + } const MixedStrategyProfile equilibrium = system.ExtractEquilibrium(x); + if (equilibrium.GetMaxRegret() > p_maxRegret) { + return {}; + } p_onEquilibrium(equilibrium); equilibria.push_back(equilibrium); return equilibria; diff --git a/src/solvers/hp/hp.h b/src/solvers/hp/hp.h index 4bc5a7280f..b600a1e020 100644 --- a/src/solvers/hp/hp.h +++ b/src/solvers/hp/hp.h @@ -45,7 +45,7 @@ inline void NullHPEventCallback(const HPEvent &) {} /// @brief Compute a Nash equilibrium of a game using the homotopy method of /// Herings and Peeters (2001) std::list> -HPStrategySolve(const MixedStrategyProfile &p_prior, +HPStrategySolve(const MixedStrategyProfile &p_prior, double p_maxRegret = 1.0e-8, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, HPEventCallbackType p_onEvent = NullHPEventCallback, const CancelToken &p_cancel = CancelToken()); diff --git a/src/solvers/path/path.cc b/src/solvers/path/path.cc index 0a6fcba301..7db29c9bbd 100644 --- a/src/solvers/path/path.cc +++ b/src/solvers/path/path.cc @@ -132,14 +132,15 @@ TracePathResult PathTracer::TracePath( CallbackFunctionType p_callback, CriterionFunctionType p_criterion, CriterionBracketFunctionType p_criterionBracket, const CancelToken &p_cancel) const { - const double c_tol = 1.0e-4; // tolerance for corrector iteration - const double c_maxDist = 0.4; // maximal distance to curve - const double c_maxContr = 0.6; // maximal contraction rate in corrector - const double c_eta = 0.1; // perturbation to avoid cancellation - // in calculating contraction rate - double h = m_hStart; // initial stepsize - const double c_hmin = 1.0e-8; // minimal stepsize - const int c_maxIter = 100; // maximum iterations in corrector + const double c_tol = 1.0e-4; // tolerance for corrector iteration + const double c_maxDist = 0.4; // maximal distance to curve + const double c_maxContr = 0.6; // maximal contraction rate in corrector + const double c_eta = 0.1; // perturbation to avoid cancellation + // in calculating contraction rate + double h = m_hStart; // initial stepsize + const double c_hmin = 1.0e-8; // minimal stepsize + const int c_maxIter = 100; // maximum iterations in corrector + const double c_newtonTol = 1.0e-8; // tolerance for Newton convergence bool newton = false; // using Newton steplength (for zero-finding) const double c_pert = 0.0000001; // The size of perturbation to apply to avoid bifurcation traps @@ -147,6 +148,9 @@ TracePathResult PathTracer::TracePath( double pert_countdown = 0.0; // How much longer (in arclength) to apply perturbation const double c_orientTol = 1.0e-8; // tolerance for detecting change in orientation + const double b_tol = 1.0e-10; // Tolerance for perturbing the b matrix in case of singularity + const double b_pert = 1.0e-8; // Perturbation of the b matrix in case of singularity + Vector u(x.size()); // t is current tangent at x; newT is tangent at u, which is the next point. Vector t(x.size()), newT(x.size()); @@ -158,11 +162,22 @@ TracePathResult PathTracer::TracePath( QRDecomp(b, q); q.GetRow(q.NumRows(), t); p_callback(x); + int steps = 0; + + auto stepsizeBelowMinimum = [&]() -> TracePathResult { + if (newton && std::abs(p_criterion(x, t)) < c_newtonTol) { + return {x, true, + "Path following terminated successfully at point satisfying criterion function.", + steps}; + } + return {x, false, "Stepsize fell below minimum threshold.", steps}; + }; + bool first_step = true; double omega = (p_direction == TraceDirection::Positive) ? 1.0 : -1.0; if (p_trackingIndex > x.size() || p_trackingIndex < 1) { - return {x, false, "Tracking index exceeds dimension of point vector."}; + return {x, false, "Tracking index exceeds dimension of point vector.", steps}; } while (!p_terminate(x)) { @@ -171,16 +186,17 @@ TracePathResult PathTracer::TracePath( bool accept = true; if (std::abs(h) <= c_hmin) { - return {x, false, "Stepsize fell below minimum threshold."}; + return stepsizeBelowMinimum(); } if (first_step) { if (std::abs(t[p_trackingIndex]) <= c_orientTol) { - return {x, false, "Initial tangent vector is orthogonal to path-following direction."}; + return {x, false, "Initial tangent vector is orthogonal to path-following direction.", + steps}; } // Ensure that the tangent is oriented in the same direction as // the path-following direction. - else if (t[p_trackingIndex] < -c_orientTol) { + if (t[p_trackingIndex] < -c_orientTol) { omega *= -1.0; } first_step = false; @@ -195,6 +211,18 @@ TracePathResult PathTracer::TracePath( p_jacobian(u, b); QRDecomp(b, q); + // Perturb the b matrix if it is singular or nearly singular + for (size_t i = 1; i < b.NumRows(); i++) { + if (std::abs(b(i, i)) < b_tol) { + if (b(i, i) < 0) { + b(i, i) -= b_pert; + } + else { + b(i, i) += b_pert; + } + } + } + int iter = 1; double disto = 0.0; while (true) { @@ -226,7 +254,7 @@ TracePathResult PathTracer::TracePath( disto = dist; iter++; if (iter > c_maxIter) { - return {x, false, "Maximum iterations exceeded."}; + return {x, false, "Maximum iterations exceeded.", steps}; } } @@ -240,7 +268,7 @@ TracePathResult PathTracer::TracePath( // is oriented in the same direction as we were originally following if (pert_countdown == 0.0) { pert = c_pert; - pert_countdown = abs(2 * h); + pert_countdown = std::abs(2 * h); } accept = false; } @@ -248,7 +276,7 @@ TracePathResult PathTracer::TracePath( if (!accept) { h /= m_maxDecel; // PC not accepted; change stepsize and retry if (std::abs(h) <= c_hmin) { - return {x, false, "Stepsize fell below minimum threshold."}; + return stepsizeBelowMinimum(); } continue; } @@ -266,9 +294,10 @@ TracePathResult PathTracer::TracePath( p_criterionBracket(x, u); } - if (newton) { + const double diff = p_criterion(u, newT) - p_criterion(x, t); + if (newton && std::abs(diff) > c_newtonTol) { // Newton-type steplength adaptation, secant method - h *= -p_criterion(u, newT) / (p_criterion(u, newT) - p_criterion(x, t)); + h *= -p_criterion(u, newT) / diff; } else { // Standard steplength adaptation @@ -279,18 +308,88 @@ TracePathResult PathTracer::TracePath( x = u; t = newT; p_callback(x); + steps++; if (pert_countdown > 0.0) { // If we are currently perturbing in the neighborhood of a bifurcation, check to see // whether we think we are likely past it, and switch off if we are. - pert_countdown -= abs(h); + pert_countdown -= std::abs(h); if (pert_countdown < 0.0) { pert = 0.0; pert_countdown = 0.0; } } } - return {x, true, "Path tracing terminated successfully."}; + return {x, true, "Path tracing terminated successfully.", steps}; +} + +PolishResult PolishPoint(std::function &, Vector &)> p_function, + std::function &, Matrix &)> p_jacobian, + Vector &x, double fixed_value, size_t fixed_index, + TerminationFunctionType p_terminate, int max_iter, + CallbackFunctionType p_callback) +{ + x[fixed_index] = fixed_value; + + const size_t N = x.size() - 1; + Vector y(N); // Equations results + Matrix jac_full(N + 1, N); // Full Jacobian matrix (N+1 unknowns, N equations) + Matrix jac_square(N, N); // Jacobian matrix with fixed_index row removed + Matrix Q(N, N); // Orthogonal matrix from QR decomposition + Vector x_reduced(N); // Reduced x vector with fixed_index removed + + int steps = 0; + double dist = 0.0; + + while (!p_terminate(x)) { + if (steps >= max_iter) { + return {x, false, "Polishing exceeded maximum iterations.", steps}; + } + + p_function(x, y); + p_jacobian(x, jac_full); + + size_t row_index = 1; + for (size_t i = 1; i <= N + 1; ++i) { // Newton step expects the transposed Jacobian + if (i != fixed_index) { + for (size_t j = 1; j <= N; ++j) { + jac_square(row_index, j) = jac_full(i, j); + } + row_index++; + } + } + + // Reduced x vector removing fixed_index + size_t temp_idx = 1; + for (size_t i = 1; i <= N + 1; ++i) { + if (i != fixed_index) { + x_reduced[temp_idx++] = x[i]; + } + } + + QRDecomp(jac_square, Q); + + // Solve jac_square * x_reduced = -y + NewtonStep(Q, jac_square, x_reduced, y, dist); + + // Update x, keeping fixed_index constant + temp_idx = 1; + for (size_t i = 1; i <= N + 1; ++i) { + if (i != fixed_index) { + x[i] = x_reduced[temp_idx++]; + } + } + + steps++; + + if (p_callback) { + p_callback(x); + } + } + + // The loop only exits here once p_terminate(x) holds for the actual, current x; + // there is nothing further to validate against a separate tolerance. + return {x, true, "Polishing terminated successfully.", steps}; } } // end namespace Gambit diff --git a/src/solvers/path/path.h b/src/solvers/path/path.h index d05b1c9adf..43cf9aff12 100644 --- a/src/solvers/path/path.h +++ b/src/solvers/path/path.h @@ -63,8 +63,15 @@ struct TracePathResult { Vector final_point; bool status; // true if path tracing terminated successfully, false if it terminated due to error std::string message; // error message if status is false + int steps; // Step at which the tracing terminated }; +struct PolishResult { + Vector final_point; + bool status; // true if polishing terminated successfully, false if it terminated due to error + std::string message; // error message if status is false + int steps; // Step at which the polishing terminated +}; // // This class implements a generic path-following algorithm for smooth curves. // It is based on the ideas and codes presented in Allgower and Georg's @@ -96,6 +103,15 @@ class PathTracer { double m_maxDecel{1.1}, m_hStart{0.03}; }; +// This function reduces the regret of a point that is close to an equilibrium that has been found +// by the path-following algorithm. Fixing the value of a component of the point, it uses a +// Newton-type method to find a nearby point with lower regret. +PolishResult PolishPoint(std::function &, Vector &)> p_function, + std::function &, Matrix &)> p_jacobian, + Vector &p_x, double fixed_value, size_t fixed_index, + TerminationFunctionType p_terminate, int max_iter = 100, + CallbackFunctionType p_callback = NullCallbackFunction); + } // end namespace Gambit #endif // GAMBIT_SOLVERS_LOGIT_PATH_H