From a12e3feb9fffdf09e1012d2ee51fdd79502814a1 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 25 Aug 2026 11:15:17 +0100 Subject: [PATCH 1/4] Expose Nash solver callbacks to pygambit Wires up the equilibrium-found and solver-specific event callbacks already present in the C++ solvers (enumpure, lcp, enummixed, lp, liap, simpdiv, ipa, gnm, logit, enumpoly) so pygambit's solve functions accept eqm_callback/event_callback keyword arguments, invoked synchronously as the computation runs. See issue #894. --- src/.gitignore | 1 + src/pygambit/callback.h | 433 +++++++++++++++++++++++++++++++++ src/pygambit/gambit.pxd | 75 ++++-- src/pygambit/nash.h | 24 +- src/pygambit/nash.pxi | 514 ++++++++++++++++++++++++++++++++++++---- src/pygambit/nash.py | 289 +++++++++++++++++++--- 6 files changed, 1238 insertions(+), 98 deletions(-) create mode 100644 src/pygambit/callback.h diff --git a/src/.gitignore b/src/.gitignore index 99d7068008..ab1a90f296 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1 +1,2 @@ pygambit/gambit.cpp +pygambit/gambit.h diff --git a/src/pygambit/callback.h b/src/pygambit/callback.h new file mode 100644 index 0000000000..0971b245eb --- /dev/null +++ b/src/pygambit/callback.h @@ -0,0 +1,433 @@ +// +// This file is part of Gambit +// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +// +// FILE: src/pygambit/callback.h +// Adapts Python callables into the C++ solver callback types +// +// 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 GAMBIT_PYGAMBIT_CALLBACK_H +#define GAMBIT_PYGAMBIT_CALLBACK_H + +#include + +#include +#include +#include +#include + +#include "core/rational.h" +#include "solvers/enumpoly/enumpoly.h" +#include "solvers/gnm/gnm.h" +#include "solvers/ipa/ipa.h" +#include "solvers/liap/liap.h" +#include "solvers/logit/logit.h" +#include "solvers/nash.h" +#include "solvers/simpdiv/simpdiv.h" + +// +// Trampolines into Cython (defined as `cdef public` functions in nash.pxi), +// each invoking a Python callback with the profile/event wrapped as the +// corresponding pygambit type. They report a Python exception raised by the +// callback by returning its description; an empty string means success. +// +std::string +InvokeStrategyCallbackDouble(PyObject *p_callback, + std::shared_ptr> p_profile); +std::string InvokeStrategyCallbackRational( + PyObject *p_callback, + std::shared_ptr> p_profile); +std::string +InvokeBehaviorCallbackDouble(PyObject *p_callback, + std::shared_ptr> p_profile); +std::string InvokeBehaviorCallbackRational( + PyObject *p_callback, + std::shared_ptr> p_profile); +std::string +InvokeLogitStrategyEventCallback(PyObject *p_callback, + std::shared_ptr p_qre); +std::string +InvokeLogitBehaviorEventCallback(PyObject *p_callback, + std::shared_ptr p_qre); +std::string InvokeGNMPerturbationEventCallback( + PyObject *p_callback, std::shared_ptr> p_profile); +std::string +InvokeGNMStartEventCallback(PyObject *p_callback, + std::shared_ptr> p_profile); +std::string +InvokeGNMStepEventCallback(PyObject *p_callback, + std::shared_ptr> p_profile, + double p_lambda); +std::string InvokeGNMTerminationEventCallback(PyObject *p_callback, int p_reason, + std::string p_message); +std::string InvokeLiapStrategyStartEventCallback( + PyObject *p_callback, std::shared_ptr> p_profile); +std::string +InvokeLiapStrategyEndEventCallback(PyObject *p_callback, + std::shared_ptr> p_profile, + double p_regret); +std::string InvokeLiapBehaviorStartEventCallback( + PyObject *p_callback, std::shared_ptr> p_profile); +std::string +InvokeLiapBehaviorEndEventCallback(PyObject *p_callback, + std::shared_ptr> p_profile, + double p_regret); +std::string InvokeSimpdivStartEventCallback( + PyObject *p_callback, + std::shared_ptr> p_profile); +std::string InvokeSimpdivRefinementEventCallback( + PyObject *p_callback, + std::shared_ptr> p_profile, + Gambit::Rational p_gridSize, Gambit::Rational p_regret); +std::string +InvokeIPAStepEventCallback(PyObject *p_callback, + std::shared_ptr> p_profile, + int p_iteration, double p_zDiff, double p_sDiff); +std::string InvokeIPATerminationEventCallback(PyObject *p_callback, int p_reason, + std::string p_message); +std::string InvokeEnumPolyCandidateSupportEventCallback( + PyObject *p_callback, std::shared_ptr p_support); +std::string InvokeEnumPolySingularSupportEventCallback( + PyObject *p_callback, std::shared_ptr p_support); +std::string InvokeEnumPolyBudgetExceededSupportEventCallback( + PyObject *p_callback, std::shared_ptr p_support); + +namespace Gambit { + +/// Raises a std::runtime_error carrying the description of a Python +/// exception caught by one of the Invoke*Callback trampolines above. +inline void ThrowIfPythonError(const std::string &p_error) +{ + if (!p_error.empty()) { + throw std::runtime_error(p_error); + } +} + +} // namespace Gambit + +/// +/// Builds a Nash::StrategyCallbackType which, when invoked, calls a +/// Python callable with the profile found so far, converted to the +/// corresponding pygambit type. A null callback (Python `None`) yields the +/// solver's own no-op default, at no cost beyond the check. +/// +template +Gambit::Nash::StrategyCallbackType MakeStrategyCallback(PyObject *p_callback); + +template <> +inline Gambit::Nash::StrategyCallbackType +MakeStrategyCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullStrategyCallback; + } + return [p_callback](const Gambit::MixedStrategyProfile &p_profile) { + Gambit::ThrowIfPythonError(InvokeStrategyCallbackDouble( + p_callback, std::make_shared>(p_profile))); + }; +} + +template <> +inline Gambit::Nash::StrategyCallbackType +MakeStrategyCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullStrategyCallback; + } + return [p_callback](const Gambit::MixedStrategyProfile &p_profile) { + Gambit::ThrowIfPythonError(InvokeStrategyCallbackRational( + p_callback, std::make_shared>(p_profile))); + }; +} + +template +Gambit::Nash::BehaviorCallbackType MakeBehaviorCallback(PyObject *p_callback); + +template <> +inline Gambit::Nash::BehaviorCallbackType +MakeBehaviorCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullBehaviorCallback; + } + return [p_callback](const Gambit::MixedBehaviorProfile &p_profile) { + Gambit::ThrowIfPythonError(InvokeBehaviorCallbackDouble( + p_callback, std::make_shared>(p_profile))); + }; +} + +template <> +inline Gambit::Nash::BehaviorCallbackType +MakeBehaviorCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullBehaviorCallback; + } + return [p_callback](const Gambit::MixedBehaviorProfile &p_profile) { + Gambit::ThrowIfPythonError(InvokeBehaviorCallbackRational( + p_callback, std::make_shared>(p_profile))); + }; +} + +/// +/// Builds a LogitEventCallbackType which, when invoked with a +/// path-tracing event, calls a Python callable with the QRE point traced so +/// far. A null callback (Python `None`) yields the solver's own no-op +/// default. +/// +template +Gambit::LogitEventCallbackType MakeLogitEventCallback(PyObject *p_callback); + +template <> +inline Gambit::LogitEventCallbackType +MakeLogitEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::NullLogitEventCallback; + } + return [p_callback](const Gambit::LogitEvent &p_event) { + const auto &qre = + std::get>(p_event).qre; + Gambit::ThrowIfPythonError(InvokeLogitStrategyEventCallback( + p_callback, std::make_shared(qre))); + }; +} + +template <> +inline Gambit::LogitEventCallbackType +MakeLogitEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::NullLogitEventCallback; + } + return [p_callback](const Gambit::LogitEvent &p_event) { + const auto &qre = + std::get>(p_event).qre; + Gambit::ThrowIfPythonError(InvokeLogitBehaviorEventCallback( + p_callback, std::make_shared(qre))); + }; +} + +/// +/// Builds a Nash::GNMEventCallbackType which, when invoked, dispatches to +/// whichever Invoke*EventCallback trampoline matches the alternative held by +/// the event, calling a Python callable with the corresponding pygambit +/// event object. A null callback (Python `None`) yields the solver's own +/// no-op default. +/// +inline Gambit::Nash::GNMEventCallbackType MakeGNMEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullGNMEventCallback; + } + return [p_callback](const Gambit::Nash::GNMEvent &p_event) { + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeGNMPerturbationEventCallback( + p_callback, + std::make_shared>(event.profile))); + } + else if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeGNMStartEventCallback( + p_callback, + std::make_shared>(event.profile))); + } + else if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeGNMStepEventCallback( + p_callback, std::make_shared>(event.profile), + event.lambda)); + } + else if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeGNMTerminationEventCallback( + p_callback, static_cast(event.reason), event.message)); + } + }, + p_event); + }; +} + +/// +/// Builds a Nash::LiapEventCallbackType which, when invoked, +/// dispatches to whichever Invoke*EventCallback trampoline matches the +/// alternative held by the event (start of a minimization run, or its end +/// with the regret attained), calling a Python callable with the +/// corresponding pygambit event object. A null callback (Python `None`) +/// yields the solver's own no-op default. +/// +template +Gambit::Nash::LiapEventCallbackType MakeLiapEventCallback(PyObject *p_callback); + +template <> +inline Gambit::Nash::LiapEventCallbackType> +MakeLiapEventCallback>(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullLiapEventCallback>; + } + return [p_callback]( + const Gambit::Nash::LiapEvent> &p_event) { + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v>>) { + Gambit::ThrowIfPythonError(InvokeLiapStrategyStartEventCallback( + p_callback, + std::make_shared>(event.profile))); + } + else if constexpr (std::is_same_v>>) { + Gambit::ThrowIfPythonError(InvokeLiapStrategyEndEventCallback( + p_callback, std::make_shared>(event.profile), + event.regret)); + } + }, + p_event); + }; +} + +template <> +inline Gambit::Nash::LiapEventCallbackType> +MakeLiapEventCallback>(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullLiapEventCallback>; + } + return [p_callback]( + const Gambit::Nash::LiapEvent> &p_event) { + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v>>) { + Gambit::ThrowIfPythonError(InvokeLiapBehaviorStartEventCallback( + p_callback, + std::make_shared>(event.profile))); + } + else if constexpr (std::is_same_v>>) { + Gambit::ThrowIfPythonError(InvokeLiapBehaviorEndEventCallback( + p_callback, std::make_shared>(event.profile), + event.regret)); + } + }, + p_event); + }; +} + +/// +/// Builds a Nash::SimpdivEventCallbackType which, when invoked, dispatches +/// to whichever Invoke*EventCallback trampoline matches the alternative +/// held by the event. A null callback (Python `None`) yields the solver's +/// own no-op default. +/// +inline Gambit::Nash::SimpdivEventCallbackType MakeSimpdivEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullSimpdivEventCallback; + } + return [p_callback](const Gambit::Nash::SimpdivEvent &p_event) { + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeSimpdivStartEventCallback( + p_callback, + std::make_shared>(event.profile))); + } + else if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeSimpdivRefinementEventCallback( + p_callback, + std::make_shared>(event.profile), + event.gridSize, event.regret)); + } + }, + p_event); + }; +} + +/// +/// Builds a Nash::IPAEventCallbackType which, when invoked, dispatches to +/// whichever Invoke*EventCallback trampoline matches the alternative held +/// by the event. A null callback (Python `None`) yields the solver's own +/// no-op default. +/// +inline Gambit::Nash::IPAEventCallbackType MakeIPAEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullIPAEventCallback; + } + return [p_callback](const Gambit::Nash::IPAEvent &p_event) { + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeIPAStepEventCallback( + p_callback, std::make_shared>(event.profile), + event.iteration, event.zDiff, event.sDiff)); + } + else if constexpr (std::is_same_v) { + Gambit::ThrowIfPythonError(InvokeIPATerminationEventCallback( + p_callback, static_cast(event.reason), event.message)); + } + }, + p_event); + }; +} + +/// +/// Builds a Nash::EnumPolyEventCallbackType which, when invoked, +/// dispatches to whichever Invoke*EventCallback trampoline matches the +/// alternative held by the event, calling a Python callable with the +/// corresponding pygambit event object. A null callback (Python `None`) +/// yields the solver's own no-op default. Only instantiated for +/// Support = StrategySupportProfile: pygambit does not yet expose a +/// BehaviorSupportProfile type, so the enumpoly_solve() behavior-form path +/// keeps using the solver's own null event callback, matching the GUI. +/// +template +Gambit::Nash::EnumPolyEventCallbackType MakeEnumPolyEventCallback(PyObject *p_callback); + +template <> +inline Gambit::Nash::EnumPolyEventCallbackType +MakeEnumPolyEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullEnumPolyEventCallback; + } + return [p_callback](const Gambit::Nash::EnumPolyEvent &p_event) { + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeEnumPolyCandidateSupportEventCallback( + p_callback, std::make_shared(event.support))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeEnumPolySingularSupportEventCallback( + p_callback, std::make_shared(event.support))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeEnumPolyBudgetExceededSupportEventCallback( + p_callback, std::make_shared(event.support))); + } + }, + p_event); + }; +} + +#endif // GAMBIT_PYGAMBIT_CALLBACK_H diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index edbc9dbaa3..8336a460e7 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -545,46 +545,88 @@ cdef extern from "util.h": ) except + +cdef extern from "callback.h": + cppclass StrategyCallbackType "Gambit::Nash::StrategyCallbackType"[T]: + pass + cppclass BehaviorCallbackType "Gambit::Nash::BehaviorCallbackType"[T]: + pass + cppclass LogitEventCallbackType "Gambit::LogitEventCallbackType"[T]: + pass + cppclass GNMEventCallbackType "Gambit::Nash::GNMEventCallbackType": + pass + cppclass LiapEventCallbackType "Gambit::Nash::LiapEventCallbackType"[T]: + pass + cppclass SimpdivEventCallbackType "Gambit::Nash::SimpdivEventCallbackType": + pass + cppclass IPAEventCallbackType "Gambit::Nash::IPAEventCallbackType": + pass + cppclass EnumPolyEventCallbackType "Gambit::Nash::EnumPolyEventCallbackType"[T]: + pass + + StrategyCallbackType[T] MakeStrategyCallback[T](object) + BehaviorCallbackType[T] MakeBehaviorCallback[T](object) + LogitEventCallbackType[T] MakeLogitEventCallback[T](object) + GNMEventCallbackType MakeGNMEventCallback(object) + LiapEventCallbackType[T] MakeLiapEventCallback[T](object) + SimpdivEventCallbackType MakeSimpdivEventCallback(object) + IPAEventCallbackType MakeIPAEventCallback(object) + EnumPolyEventCallbackType[T] MakeEnumPolyEventCallback[T](object) + cdef extern from "solvers/enumpure/enumpure.h": - stdlist[c_MixedStrategyProfile[c_Rational]] EnumPureStrategySolve(c_Game) except +RuntimeError - stdlist[c_MixedBehaviorProfile[c_Rational]] EnumPureAgentSolve(c_Game) except +RuntimeError + stdlist[c_MixedStrategyProfile[c_Rational]] EnumPureStrategySolve( + c_Game, StrategyCallbackType[c_Rational] + ) except +RuntimeError + stdlist[c_MixedBehaviorProfile[c_Rational]] EnumPureAgentSolve( + c_Game, BehaviorCallbackType[c_Rational] + ) except +RuntimeError cdef extern from "solvers/enummixed/enummixed.h": - stdlist[c_MixedStrategyProfile[T]] EnumMixedStrategySolve[T](c_Game) except +RuntimeError + stdlist[c_MixedStrategyProfile[T]] EnumMixedStrategySolve[T]( + c_Game, StrategyCallbackType[T] + ) except +RuntimeError cdef extern from "solvers/lcp/lcp.h": stdlist[c_MixedStrategyProfile[T]] LcpStrategySolve[T]( - c_Game, optional[size_t] p_stopAfter, int p_maxDepth + c_Game, optional[size_t] p_stopAfter, int p_maxDepth, StrategyCallbackType[T] + ) except +RuntimeError + stdlist[c_MixedBehaviorProfile[T]] LcpBehaviorSolve[T]( + c_Game, BehaviorCallbackType[T] ) except +RuntimeError - stdlist[c_MixedBehaviorProfile[T]] LcpBehaviorSolve[T](c_Game) except +RuntimeError cdef extern from "solvers/lp/lp.h": - stdlist[c_MixedStrategyProfile[T]] LpStrategySolve[T](c_Game) except +RuntimeError - stdlist[c_MixedBehaviorProfile[T]] LpBehaviorSolve[T](c_Game) except +RuntimeError + stdlist[c_MixedStrategyProfile[T]] LpStrategySolve[T]( + c_Game, StrategyCallbackType[T] + ) except +RuntimeError + stdlist[c_MixedBehaviorProfile[T]] LpBehaviorSolve[T]( + c_Game, BehaviorCallbackType[T] + ) except +RuntimeError cdef extern from "solvers/liap/liap.h": stdlist[c_MixedStrategyProfile[double]] LiapStrategySolve( - c_MixedStrategyProfile[double], double p_maxregret, int p_maxitsN + c_MixedStrategyProfile[double], double p_maxregret, int p_maxitsN, + StrategyCallbackType[double], LiapEventCallbackType[c_MixedStrategyProfile[double]] ) except +RuntimeError stdlist[c_MixedBehaviorProfile[double]] LiapAgentSolve( - c_MixedBehaviorProfile[double], double p_maxregret, int p_maxitsN + c_MixedBehaviorProfile[double], double p_maxregret, int p_maxitsN, + BehaviorCallbackType[double], LiapEventCallbackType[c_MixedBehaviorProfile[double]] ) except +RuntimeError cdef extern from "solvers/simpdiv/simpdiv.h": stdlist[c_MixedStrategyProfile[c_Rational]] SimpdivStrategySolve( c_MixedStrategyProfile[c_Rational] start, c_Rational p_maxregret, int p_gridResize, - int p_leashLength + int p_leashLength, StrategyCallbackType[c_Rational], SimpdivEventCallbackType ) except +RuntimeError cdef extern from "solvers/ipa/ipa.h": stdlist[c_MixedStrategyProfile[double]] IPAStrategySolve( - c_MixedStrategyProfile[double] + c_MixedStrategyProfile[double], StrategyCallbackType[double], IPAEventCallbackType ) except +RuntimeError cdef extern from "solvers/gnm/gnm.h": stdlist[c_MixedStrategyProfile[double]] GNMStrategySolve( c_MixedStrategyProfile[double], double p_endLambda, int p_steps, - int p_localNewtonInterval, int p_localNewtonMaxits + int p_localNewtonInterval, int p_localNewtonMaxits, + StrategyCallbackType[double], GNMEventCallbackType ) except +RuntimeError cdef extern from "solvers/nashsupport/nashsupport.h": @@ -594,10 +636,11 @@ cdef extern from "solvers/nashsupport/nashsupport.h": cdef extern from "solvers/enumpoly/enumpoly.h": stdlist[c_MixedStrategyProfile[double]] EnumPolyStrategySolve( - c_Game, optional[size_t], float, size_t + c_Game, optional[size_t], float, size_t, + StrategyCallbackType[double], EnumPolyEventCallbackType[c_StrategySupportProfile] ) except +RuntimeError stdlist[c_MixedBehaviorProfile[double]] EnumPolyBehaviorSolve( - c_Game, optional[size_t], float, size_t + c_Game, optional[size_t], float, size_t, BehaviorCallbackType[double] ) except +RuntimeError cdef extern from "solvers/logit/logit.h": @@ -624,7 +667,7 @@ cdef extern from "solvers/logit/logit.h": cdef extern from "nash.h": stdlist[c_MixedBehaviorProfile[double]] LogitBehaviorSolveWrapper( - c_Game, double, double, double + c_Game, double, double, double, LogitEventCallbackType[c_LogitQREMixedBehaviorProfile] ) except + stdlist[c_LogitQREMixedBehaviorProfile] LogitBehaviorPrincipalBranchWrapper( c_Game, double, double, double @@ -636,7 +679,7 @@ cdef extern from "nash.h": shared_ptr[c_MixedBehaviorProfile[double]], bool, double, double ) except + stdlist[c_MixedStrategyProfile[double]] LogitStrategySolveWrapper( - c_Game, double, double, double + c_Game, double, double, double, LogitEventCallbackType[c_LogitQREMixedStrategyProfile] ) except + stdlist[c_LogitQREMixedStrategyProfile] LogitStrategyPrincipalBranchWrapper( c_Game, double, double, double diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 722ba0cd15..1e3b3609a5 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -25,14 +25,16 @@ using namespace std; using namespace Gambit; -std::list> LogitBehaviorSolveWrapper(const Game &p_game, - double p_regret, - double p_firstStep, - double p_maxAccel) +std::list> +LogitBehaviorSolveWrapper(const Game &p_game, double p_regret, double p_firstStep, + double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback) { std::list> ret; ret.push_back(LogitBehaviorSolve(LogitQREMixedBehaviorProfile(p_game), p_regret, 1.0, - p_firstStep, p_maxAccel) + p_firstStep, p_maxAccel, Nash::NullBehaviorCallback, + p_onEvent) .back() .GetProfile()); return ret; @@ -66,14 +68,16 @@ LogitBehaviorAtLambdaWrapper(const Game &p_game, const std::list &p_targ return ret; } -std::list> LogitStrategySolveWrapper(const Game &p_game, - double p_regret, - double p_firstStep, - double p_maxAccel) +std::list> +LogitStrategySolveWrapper(const Game &p_game, double p_regret, double p_firstStep, + double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback) { std::list> ret; ret.push_back(LogitStrategySolve(LogitQREMixedStrategyProfile(p_game), p_regret, 1.0, - p_firstStep, p_maxAccel) + p_firstStep, p_maxAccel, Nash::NullStrategyCallback, + p_onEvent) .back() .GetProfile()); return ret; diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index 4a5a14d00f..c1df4ca7e7 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -20,9 +20,357 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # import cython +import dataclasses +import enum from libcpp.memory cimport shared_ptr, make_shared from cython.operator cimport dereference as deref from libcpp.list cimport list as stdlist +from libcpp.string cimport string + + +class GNMTerminationReason(enum.Enum): + """Why a :ref:`global Newton method ` path-following run terminated.""" + NO_MORE_BOUNDARIES = 0 + NO_NEXT_BOUNDARY = 1 + NONFINITE_STRATEGY = 2 + LAMBDA_OUT_OF_RANGE = 3 + EXCESSIVE_ERROR = 4 + + +@dataclasses.dataclass(frozen=True) +class GNMPerturbationEvent: + """Reports the perturbation vector a GNM path is about to be traced from.""" + profile: MixedStrategyProfileDouble + + +@dataclasses.dataclass(frozen=True) +class GNMStartEvent: + """Reports the starting point of a GNM path, after following the perturbation ray.""" + profile: MixedStrategyProfileDouble + + +@dataclasses.dataclass(frozen=True) +class GNMStepEvent: + """Reports one predictor-corrector step taken while tracing a GNM path.""" + profile: MixedStrategyProfileDouble + lam: float + + +@dataclasses.dataclass(frozen=True) +class GNMTerminationEvent: + """Reports why a GNM path-following run terminated.""" + reason: GNMTerminationReason + message: str + + +@dataclasses.dataclass(frozen=True) +class LiapStartEvent: + """Reports the starting point of a :ref:`Lyapunov function minimization ` run.""" + profile: MixedStrategyProfileDouble | MixedBehaviorProfileDouble + + +@dataclasses.dataclass(frozen=True) +class LiapEndEvent: + """Reports the point, and regret attained, at the end of a + :ref:`Lyapunov function minimization ` run. + """ + profile: MixedStrategyProfileDouble | MixedBehaviorProfileDouble + regret: float + + +@dataclasses.dataclass(frozen=True) +class SimpdivStartEvent: + """Reports the starting point of a :ref:`simplicial subdivision ` run.""" + profile: MixedStrategyProfileRational + + +@dataclasses.dataclass(frozen=True) +class SimpdivRefinementEvent: + """Reports the profile, grid size, and regret reached each time + :ref:`simplicial subdivision ` refines its triangulation. + """ + profile: MixedStrategyProfileRational + grid_size: Rational + regret: Rational + + +class IPATerminationReason(enum.Enum): + """Why a call to :ref:`iterated polymatrix approximation ` terminated.""" + CONVERGED = 0 + MAX_ITERATIONS_REACHED = 1 + NONFINITE_STRATEGY = 2 + + +@dataclasses.dataclass(frozen=True) +class IPAStepEvent: + """Reports the state of the approximating strategy profile at the end of one + :ref:`iterated polymatrix approximation ` iteration. + """ + profile: MixedStrategyProfileDouble + iteration: int + z_diff: float + s_diff: float + + +@dataclasses.dataclass(frozen=True) +class IPATerminationEvent: + """Reports why a call to :ref:`iterated polymatrix approximation ` terminated. + + Because IPA may internally restart the iteration from where it left off if it has + not yet converged, this may be raised more than once in the course of solving. + """ + reason: IPATerminationReason + message: str + + +@dataclasses.dataclass(frozen=True) +class EnumPolyCandidateSupportEvent: + """Reports a support profile examined by :ref:`enumpoly ` as a candidate + to contain a totally-mixed equilibrium. + """ + support: StrategySupportProfile + + +@dataclasses.dataclass(frozen=True) +class EnumPolySingularSupportEvent: + """Reports a support profile skipped by :ref:`enumpoly ` because the + system of equations over it was singular. + """ + support: StrategySupportProfile + + +@dataclasses.dataclass(frozen=True) +class EnumPolyBudgetExceededSupportEvent: + """Reports a support profile on which :ref:`enumpoly ` exhausted its + rectangle budget before completing the search for roots. + """ + support: StrategySupportProfile + + +cdef public string InvokeStrategyCallbackDouble( + callback, profile: shared_ptr[c_MixedStrategyProfile[float]] +): + try: + callback(MixedStrategyProfileDouble.wrap(profile)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeStrategyCallbackRational( + callback, profile: shared_ptr[c_MixedStrategyProfile[c_Rational]] +): + try: + callback(MixedStrategyProfileRational.wrap(profile)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeBehaviorCallbackDouble( + callback, profile: shared_ptr[c_MixedBehaviorProfile[float]] +): + try: + callback(MixedBehaviorProfileDouble.wrap(profile)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeBehaviorCallbackRational( + callback, profile: shared_ptr[c_MixedBehaviorProfile[c_Rational]] +): + try: + callback(MixedBehaviorProfileRational.wrap(profile)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLogitStrategyEventCallback( + callback, qre: shared_ptr[c_LogitQREMixedStrategyProfile] +): + try: + callback(LogitQREMixedStrategyProfile.wrap(qre)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLogitBehaviorEventCallback( + callback, qre: shared_ptr[c_LogitQREMixedBehaviorProfile] +): + ret = LogitQREMixedBehaviorProfile() + ret.thisptr = qre + try: + callback(ret) + 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]] +): + try: + callback(GNMPerturbationEvent(profile=MixedStrategyProfileDouble.wrap(profile))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeGNMStartEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[float]] +): + try: + callback(GNMStartEvent(profile=MixedStrategyProfileDouble.wrap(profile))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeGNMStepEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[float]], lam: float +): + try: + callback(GNMStepEvent(profile=MixedStrategyProfileDouble.wrap(profile), lam=lam)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeGNMTerminationEventCallback( + callback, reason: cython.int, message: string +): + try: + callback(GNMTerminationEvent( + reason=GNMTerminationReason(reason), message=message.decode("utf-8") + )) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLiapStrategyStartEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[float]] +): + try: + callback(LiapStartEvent(profile=MixedStrategyProfileDouble.wrap(profile))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLiapStrategyEndEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[float]], regret: float +): + try: + callback(LiapEndEvent(profile=MixedStrategyProfileDouble.wrap(profile), regret=regret)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLiapBehaviorStartEventCallback( + callback, profile: shared_ptr[c_MixedBehaviorProfile[float]] +): + try: + callback(LiapStartEvent(profile=MixedBehaviorProfileDouble.wrap(profile))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLiapBehaviorEndEventCallback( + callback, profile: shared_ptr[c_MixedBehaviorProfile[float]], regret: float +): + try: + callback(LiapEndEvent(profile=MixedBehaviorProfileDouble.wrap(profile), regret=regret)) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeSimpdivStartEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[c_Rational]] +): + try: + callback(SimpdivStartEvent(profile=MixedStrategyProfileRational.wrap(profile))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeSimpdivRefinementEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[c_Rational]], + grid_size: c_Rational, regret: c_Rational +): + try: + callback(SimpdivRefinementEvent( + profile=MixedStrategyProfileRational.wrap(profile), + grid_size=rat_to_py(grid_size), regret=rat_to_py(regret) + )) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeIPAStepEventCallback( + callback, profile: shared_ptr[c_MixedStrategyProfile[float]], + iteration: cython.int, z_diff: float, s_diff: float +): + try: + callback(IPAStepEvent( + profile=MixedStrategyProfileDouble.wrap(profile), + iteration=iteration, z_diff=z_diff, s_diff=s_diff + )) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeIPATerminationEventCallback( + callback, reason: cython.int, message: string +): + try: + callback(IPATerminationEvent( + reason=IPATerminationReason(reason), message=message.decode("utf-8") + )) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeEnumPolyCandidateSupportEventCallback( + callback, support: shared_ptr[c_StrategySupportProfile] +): + try: + callback(EnumPolyCandidateSupportEvent(support=StrategySupportProfile.wrap(support))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeEnumPolySingularSupportEventCallback( + callback, support: shared_ptr[c_StrategySupportProfile] +): + try: + callback(EnumPolySingularSupportEvent(support=StrategySupportProfile.wrap(support))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeEnumPolyBudgetExceededSupportEventCallback( + callback, support: shared_ptr[c_StrategySupportProfile] +): + try: + callback( + EnumPolyBudgetExceededSupportEvent(support=StrategySupportProfile.wrap(support)) + ) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" @cython.cfunc @@ -57,93 +405,157 @@ def _convert_mbpr( for profile in make_list_of_pointer(inlist)] -def _enumpure_strategy_solve(game: Game) -> list[MixedStrategyProfile[c_Rational]]: - return _convert_mspr(EnumPureStrategySolve(game.game)) +def _enumpure_strategy_solve( + game: Game, eqm_callback: object = None +) -> list[MixedStrategyProfile[c_Rational]]: + return _convert_mspr( + EnumPureStrategySolve(game.game, MakeStrategyCallback[c_Rational](eqm_callback)) + ) -def _enumpure_agent_solve(game: Game) -> list[MixedBehaviorProfileRational]: - return _convert_mbpr(EnumPureAgentSolve(game.game)) +def _enumpure_agent_solve( + game: Game, eqm_callback: object = None +) -> list[MixedBehaviorProfileRational]: + return _convert_mbpr( + EnumPureAgentSolve(game.game, MakeBehaviorCallback[c_Rational](eqm_callback)) + ) -def _enummixed_strategy_solve_double(game: Game) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(EnumMixedStrategySolve[double](game.game)) +def _enummixed_strategy_solve_double( + game: Game, eqm_callback: object = None +) -> list[MixedStrategyProfileDouble]: + return _convert_mspd( + EnumMixedStrategySolve[double](game.game, MakeStrategyCallback[double](eqm_callback)) + ) -def _enummixed_strategy_solve_rational(game: Game) -> list[MixedStrategyProfileRational]: - return _convert_mspr(EnumMixedStrategySolve[c_Rational](game.game)) +def _enummixed_strategy_solve_rational( + game: Game, eqm_callback: object = None +) -> list[MixedStrategyProfileRational]: + return _convert_mspr( + EnumMixedStrategySolve[c_Rational]( + game.game, MakeStrategyCallback[c_Rational](eqm_callback) + ) + ) def _lcp_behavior_solve_double( - game: Game + game: Game, eqm_callback: object = None ) -> list[MixedBehaviorProfileDouble]: - return _convert_mbpd(LcpBehaviorSolve[double](game.game)) + return _convert_mbpd( + LcpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](eqm_callback)) + ) def _lcp_behavior_solve_rational( - game: Game + game: Game, eqm_callback: object = None ) -> list[MixedBehaviorProfileRational]: - return _convert_mbpr(LcpBehaviorSolve[c_Rational](game.game)) + return _convert_mbpr( + LcpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](eqm_callback)) + ) def _lcp_strategy_solve_double( - game: Game, stop_after, max_depth: int + game: Game, stop_after, max_depth: int, eqm_callback: object = None ) -> list[MixedStrategyProfileDouble]: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after - return _convert_mspd(LcpStrategySolve[double](game.game, c_stop_after, max_depth)) + return _convert_mspd( + LcpStrategySolve[double]( + game.game, c_stop_after, max_depth, MakeStrategyCallback[double](eqm_callback) + ) + ) def _lcp_strategy_solve_rational( - game: Game, stop_after, max_depth: int + game: Game, stop_after, max_depth: int, eqm_callback: object = None ) -> list[MixedStrategyProfileRational]: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after - return _convert_mspr(LcpStrategySolve[c_Rational](game.game, c_stop_after, max_depth)) + return _convert_mspr( + LcpStrategySolve[c_Rational]( + game.game, c_stop_after, max_depth, MakeStrategyCallback[c_Rational](eqm_callback) + ) + ) -def _lp_behavior_solve_double(game: Game) -> list[MixedBehaviorProfileDouble]: - return _convert_mbpd(LpBehaviorSolve[double](game.game)) +def _lp_behavior_solve_double( + game: Game, eqm_callback: object = None +) -> list[MixedBehaviorProfileDouble]: + return _convert_mbpd( + LpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](eqm_callback)) + ) -def _lp_behavior_solve_rational(game: Game) -> list[MixedBehaviorProfileRational]: - return _convert_mbpr(LpBehaviorSolve[c_Rational](game.game)) +def _lp_behavior_solve_rational( + game: Game, eqm_callback: object = None +) -> list[MixedBehaviorProfileRational]: + return _convert_mbpr( + LpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](eqm_callback)) + ) -def _lp_strategy_solve_double(game: Game) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(LpStrategySolve[double](game.game)) +def _lp_strategy_solve_double( + game: Game, eqm_callback: object = None +) -> list[MixedStrategyProfileDouble]: + return _convert_mspd( + LpStrategySolve[double](game.game, MakeStrategyCallback[double](eqm_callback)) + ) -def _lp_strategy_solve_rational(game: Game) -> list[MixedStrategyProfileRational]: - return _convert_mspr(LpStrategySolve[c_Rational](game.game)) +def _lp_strategy_solve_rational( + game: Game, eqm_callback: object = None +) -> list[MixedStrategyProfileRational]: + return _convert_mspr( + LpStrategySolve[c_Rational](game.game, MakeStrategyCallback[c_Rational](eqm_callback)) + ) def _liap_strategy_solve(start: MixedStrategyProfileDouble, maxregret: float, - maxiter: int) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(LiapStrategySolve(deref(start.profile), maxregret, maxiter)) + maxiter: int, + eqm_callback: object = None, + event_callback: object = None) -> list[MixedStrategyProfileDouble]: + return _convert_mspd(LiapStrategySolve( + deref(start.profile), maxregret, maxiter, + MakeStrategyCallback[double](eqm_callback), + MakeLiapEventCallback[c_MixedStrategyProfile[double]](event_callback) + )) def _liap_behavior_solve(start: MixedBehaviorProfileDouble, maxregret: float, - maxiter: int) -> list[MixedBehaviorProfileDouble]: - return _convert_mbpd(LiapAgentSolve(deref(start.profile), maxregret, maxiter)) + maxiter: int, + eqm_callback: object = None, + event_callback: object = None) -> list[MixedBehaviorProfileDouble]: + return _convert_mbpd(LiapAgentSolve( + deref(start.profile), maxregret, maxiter, + MakeBehaviorCallback[double](eqm_callback), + MakeLiapEventCallback[c_MixedBehaviorProfile[double]](event_callback) + )) def _simpdiv_strategy_solve( - start: MixedStrategyProfileRational, maxregret: Rational, gridstep: int, leash: int + start: MixedStrategyProfileRational, maxregret: Rational, gridstep: int, leash: int, + eqm_callback: object = None, event_callback: object = None ) -> list[MixedStrategyProfileRational]: - return _convert_mspr(SimpdivStrategySolve(deref(start.profile), - to_rational(str(maxregret).encode("ascii")), - gridstep, leash)) + return _convert_mspr(SimpdivStrategySolve( + deref(start.profile), to_rational(str(maxregret).encode("ascii")), gridstep, leash, + MakeStrategyCallback[c_Rational](eqm_callback), MakeSimpdivEventCallback(event_callback) + )) def _ipa_strategy_solve( - pert: MixedStrategyProfileDouble + pert: MixedStrategyProfileDouble, eqm_callback: object = None, + event_callback: object = None ) -> list[MixedStrategyProfileDouble]: try: - return _convert_mspd(IPAStrategySolve(deref(pert.profile))) + return _convert_mspd(IPAStrategySolve( + deref(pert.profile), + MakeStrategyCallback[double](eqm_callback), MakeIPAEventCallback(event_callback) + )) except RuntimeError as e: if "does not have unique maximizer" in str(e): raise ValueError(str(e)) from None @@ -156,10 +568,14 @@ def _gnm_strategy_solve( steps: int, local_newton_interval: int, local_newton_maxits: int, + eqm_callback: object = None, + event_callback: object = None, ) -> list[MixedStrategyProfileDouble]: try: - return _convert_mspd(GNMStrategySolve(deref(pert.profile), end_lambda, - steps, local_newton_interval, local_newton_maxits)) + return _convert_mspd(GNMStrategySolve( + deref(pert.profile), end_lambda, steps, local_newton_interval, local_newton_maxits, + MakeStrategyCallback[double](eqm_callback), MakeGNMEventCallback(event_callback) + )) except RuntimeError as e: if "does not have unique maximizer" in str(e): raise ValueError(str(e)) from None @@ -187,11 +603,17 @@ def _enumpoly_strategy_solve( stop_after, maxregret: float, max_rectangles: int, + eqm_callback: object = None, + event_callback: object = None, ) -> list[MixedStrategyProfileDouble]: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after - return _convert_mspd(EnumPolyStrategySolve(game.game, c_stop_after, maxregret, max_rectangles)) + return _convert_mspd(EnumPolyStrategySolve( + game.game, c_stop_after, maxregret, max_rectangles, + MakeStrategyCallback[double](eqm_callback), + MakeEnumPolyEventCallback[c_StrategySupportProfile](event_callback) + )) def _enumpoly_behavior_solve( @@ -199,23 +621,35 @@ def _enumpoly_behavior_solve( stop_after, maxregret: float, max_rectangles: int, + eqm_callback: object = None, ) -> list[MixedBehaviorProfileDouble]: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after - return _convert_mbpd(EnumPolyBehaviorSolve(game.game, c_stop_after, maxregret, max_rectangles)) + return _convert_mbpd(EnumPolyBehaviorSolve( + game.game, c_stop_after, maxregret, max_rectangles, + MakeBehaviorCallback[double](eqm_callback) + )) def _logit_strategy_solve( game: Game, maxregret: float, first_step: float, max_accel: float, + event_callback: object = None, ) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(LogitStrategySolveWrapper(game.game, maxregret, first_step, max_accel)) + return _convert_mspd(LogitStrategySolveWrapper( + game.game, maxregret, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedStrategyProfile](event_callback) + )) def _logit_behavior_solve( game: Game, maxregret: float, first_step: float, max_accel: float, + event_callback: object = None, ) -> list[MixedBehaviorProfileDouble]: - return _convert_mbpd(LogitBehaviorSolveWrapper(game.game, maxregret, first_step, max_accel)) + return _convert_mbpd(LogitBehaviorSolveWrapper( + game.game, maxregret, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) + )) @cython.cclass diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 119bd8ecfe..e5f7b9ed52 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -28,7 +28,7 @@ import dataclasses import pathlib -from collections.abc import Iterator +from collections.abc import Callable, Iterator from numbers import Integral import pygambit.gambit as libgbt @@ -68,7 +68,10 @@ class NashComputationResult: parameters: dict = dataclasses.field(default_factory=dict) -def enumpure_solve(game: libgbt.Game) -> NashComputationResult: +def enumpure_solve( + game: libgbt.Game, + eqm_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, +) -> NashComputationResult: """Compute all :ref:`pure-strategy Nash equilibria ` of game. .. versionchanged:: 16.5.0 @@ -76,11 +79,19 @@ def enumpure_solve(game: libgbt.Game) -> NashComputationResult: `use_strategic` parameter removed. The old behavior in the case of `use_strategic=False` is now available as `enumpure_agent_solve`. + .. versionchanged:: 17.0.0 + + Added `eqm_callback` parameter. + Parameters ---------- game : Game The game to compute equilibria in. + eqm_callback : Callable[[MixedStrategyProfileRational], None], optional + If specified, called with each equilibrium as it is found, before + the method continues searching for any further equilibria. + Returns ------- res : NashComputationResult @@ -95,22 +106,33 @@ def enumpure_solve(game: libgbt.Game) -> NashComputationResult: method="enumpure", rational=True, use_strategic=True, - equilibria=libgbt._enumpure_strategy_solve(game) + equilibria=libgbt._enumpure_strategy_solve(game, eqm_callback) ) -def enumpure_agent_solve(game: libgbt.Game) -> NashComputationResult: +def enumpure_agent_solve( + game: libgbt.Game, + eqm_callback: Callable[[libgbt.MixedBehaviorProfileRational], None] | None = None, +) -> NashComputationResult: """Compute all :ref:`pure-strategy agent Nash equilibria ` of game. .. versionadded:: 16.5.0 Formerly implemented as `enumpure_solve` with `use_strategic=False`. + .. versionchanged:: 17.0.0 + + Added `eqm_callback` parameter. + Parameters ---------- game : Game The game to compute agent-Nash equilibria in. + eqm_callback : Callable[[MixedBehaviorProfileRational], None], optional + If specified, called with each equilibrium as it is found, before + the method continues searching for any further equilibria. + Returns ------- res : NashComputationResult @@ -125,7 +147,7 @@ def enumpure_agent_solve(game: libgbt.Game) -> NashComputationResult: method="enumpure-agent", rational=True, use_strategic=False, - equilibria=libgbt._enumpure_agent_solve(game) + equilibria=libgbt._enumpure_agent_solve(game, eqm_callback) ) @@ -133,6 +155,9 @@ def enummixed_solve( game: libgbt.Game, rational: bool = True, lrsnash_path: pathlib.Path | str | None = None, + eqm_callback: Callable[ + [libgbt.MixedStrategyProfile], None + ] | None = None, ) -> NashComputationResult: """Compute all :ref:`mixed-strategy Nash equilibria ` of a two-player game using the strategic representation. @@ -152,6 +177,13 @@ def enummixed_solve( .. versionadded:: 16.3.0 + eqm_callback : Callable[[MixedStrategyProfile], None], optional + If specified, called with each equilibrium as it is found, before + the method continues searching for any further equilibria. Not + available when `lrsnash_path` is specified. + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -162,11 +194,18 @@ def enummixed_solve( RuntimeError If game has more than two players. + ValueError + If both `lrsnash_path` and `eqm_callback` are specified. + Notes ----- `lrsnash` is part of `lrslib`, available at http://cgm.cs.mcgill.ca/~avis/C/lrs.html """ if lrsnash_path is not None: + if eqm_callback is not None: + raise ValueError( + "enummixed_solve(): eqm_callback cannot be used with lrsnash_path" + ) equilibria = nashlrs.lrsnash_solve(game, lrsnash_path=lrsnash_path) return NashComputationResult( game=game, @@ -177,9 +216,9 @@ def enummixed_solve( equilibria=equilibria, ) if rational: - equilibria = libgbt._enummixed_strategy_solve_rational(game) + equilibria = libgbt._enummixed_strategy_solve_rational(game, eqm_callback) else: - equilibria = libgbt._enummixed_strategy_solve_double(game) + equilibria = libgbt._enummixed_strategy_solve_double(game, eqm_callback) return NashComputationResult( game=game, method="enummixed", @@ -194,7 +233,10 @@ def lcp_solve( rational: bool = True, use_strategic: bool = False, stop_after: int | None = None, - max_depth: int | None = None + max_depth: int | None = None, + eqm_callback: Callable[ + [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None + ] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a two-player game using :ref:`linear complementarity programming `. @@ -221,6 +263,12 @@ def lcp_solve( If specified, will limit the recursive search, but may result in some accessible equilibria not being found. + eqm_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional + If specified, called with each equilibrium as it is found, before + the method continues searching for any further equilibria. + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -253,13 +301,17 @@ def lcp_solve( ) if not game.is_tree or use_strategic: if rational: - equilibria = libgbt._lcp_strategy_solve_rational(game, stop_after, max_depth or 0) + equilibria = libgbt._lcp_strategy_solve_rational( + game, stop_after, max_depth or 0, eqm_callback + ) else: - equilibria = libgbt._lcp_strategy_solve_double(game, stop_after, max_depth or 0) + equilibria = libgbt._lcp_strategy_solve_double( + game, stop_after, max_depth or 0, eqm_callback + ) elif rational: - equilibria = libgbt._lcp_behavior_solve_rational(game) + equilibria = libgbt._lcp_behavior_solve_rational(game, eqm_callback) else: - equilibria = libgbt._lcp_behavior_solve_double(game) + equilibria = libgbt._lcp_behavior_solve_double(game, eqm_callback) return NashComputationResult( game=game, method="lcp", @@ -273,7 +325,10 @@ def lcp_solve( def lp_solve( game: libgbt.Game, rational: bool = True, - use_strategic: bool = False + use_strategic: bool = False, + eqm_callback: Callable[ + [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None + ] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a two-player constant-sum game using :ref:`linear programming `. @@ -291,6 +346,12 @@ def lp_solve( Whether to use the strategic form. If `True`, always uses the strategic representation even if the game's native representation is extensive. + eqm_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional + If specified, called with each equilibrium as it is found, before + the method continues searching for any further equilibria. + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -303,13 +364,13 @@ def lp_solve( """ if not game.is_tree or use_strategic: if rational: - equilibria = libgbt._lp_strategy_solve_rational(game) + equilibria = libgbt._lp_strategy_solve_rational(game, eqm_callback) else: - equilibria = libgbt._lp_strategy_solve_double(game) + equilibria = libgbt._lp_strategy_solve_double(game, eqm_callback) elif rational: - equilibria = libgbt._lp_behavior_solve_rational(game) + equilibria = libgbt._lp_behavior_solve_rational(game, eqm_callback) else: - equilibria = libgbt._lp_behavior_solve_double(game) + equilibria = libgbt._lp_behavior_solve_double(game, eqm_callback) return NashComputationResult( game=game, method="lp", @@ -322,7 +383,11 @@ def lp_solve( def liap_solve( start: libgbt.MixedStrategyProfileDouble, maxregret: float = 1.0e-4, - maxiter: int = 1000 + maxiter: int = 1000, + eqm_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, + event_callback: Callable[ + [libgbt.LiapStartEvent | libgbt.LiapEndEvent], None + ] | None = None, ) -> NashComputationResult: """Compute approximate Nash equilibria of a game using :ref:`Lyapunov function minimization `. @@ -357,6 +422,18 @@ def liap_solve( .. versionadded: 16.2.0 + eqm_callback : Callable[[MixedStrategyProfileDouble], None], optional + If specified, called with the equilibrium found, if any. + + .. versionadded:: 17.0.0 + + event_callback : Callable[[LiapStartEvent | LiapEndEvent], None], optional + If specified, called at the start of minimization (``LiapStartEvent``) + and, once, with the point and regret reached at its end + (``LiapEndEvent``). + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -364,8 +441,10 @@ def liap_solve( """ if maxregret <= 0.0: raise ValueError("liap_solve(): maxregret argument must be positive") - equilibria = libgbt._liap_strategy_solve(start, - maxregret=maxregret, maxiter=maxiter) + equilibria = libgbt._liap_strategy_solve( + start, maxregret=maxregret, maxiter=maxiter, + eqm_callback=eqm_callback, event_callback=event_callback + ) return NashComputationResult( game=start.game, method="liap", @@ -379,7 +458,11 @@ def liap_solve( def liap_agent_solve( start: libgbt.MixedBehaviorProfileDouble, maxregret: float = 1.0e-4, - maxiter: int = 1000 + maxiter: int = 1000, + eqm_callback: Callable[[libgbt.MixedBehaviorProfileDouble], None] | None = None, + event_callback: Callable[ + [libgbt.LiapStartEvent | libgbt.LiapEndEvent], None + ] | None = None, ) -> NashComputationResult: """Compute approximate agent Nash equilibria of a game using :ref:`Lyapunov function minimization `. @@ -404,6 +487,18 @@ def liap_agent_solve( maxiter : int, default 1000 Maximum number of iterations in function minimization. + eqm_callback : Callable[[MixedBehaviorProfileDouble], None], optional + If specified, called with the equilibrium found, if any. + + .. versionadded:: 17.0.0 + + event_callback : Callable[[LiapStartEvent | LiapEndEvent], None], optional + If specified, called at the start of minimization (``LiapStartEvent``) + and, once, with the point and regret reached at its end + (``LiapEndEvent``). + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -411,8 +506,10 @@ def liap_agent_solve( """ if maxregret <= 0.0: raise ValueError("liap_solve(): maxregret argument must be positive") - equilibria = libgbt._liap_behavior_solve(start, - maxregret=maxregret, maxiter=maxiter) + equilibria = libgbt._liap_behavior_solve( + start, maxregret=maxregret, maxiter=maxiter, + eqm_callback=eqm_callback, event_callback=event_callback + ) return NashComputationResult( game=start.game, method="liap-agent", @@ -427,7 +524,11 @@ def simpdiv_solve( start: libgbt.MixedStrategyProfileRational, maxregret: libgbt.Rational | None = None, refine: int = 2, - leash: int | None = None + leash: int | None = None, + eqm_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, + event_callback: Callable[ + [libgbt.SimpdivStartEvent | libgbt.SimpdivRefinementEvent], None + ] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a game using :ref:`simplicial subdivision `. @@ -461,6 +562,17 @@ def simpdiv_solve( may explore. This trades off the possibility of finding an equilibrium more quickly by giving up the guarantee than an equilibrium will necessarily be found. + eqm_callback : Callable[[MixedStrategyProfileRational], None], optional + If specified, called with the equilibrium found, if any. + + .. versionadded:: 17.0.0 + + event_callback : Callable[[SimpdivStartEvent | SimpdivRefinementEvent], None], optional + If specified, called at the starting profile (``SimpdivStartEvent``) and each + time the triangulation is refined (``SimpdivRefinementEvent``). + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -474,7 +586,9 @@ def simpdiv_solve( maxregret = libgbt.Rational(1, 10000000) elif maxregret < libgbt.Rational(0): raise ValueError("simpdiv_solve(): maxregret must be positive") - equilibria = libgbt._simpdiv_strategy_solve(start, maxregret, refine, leash or 0) + equilibria = libgbt._simpdiv_strategy_solve( + start, maxregret, refine, leash or 0, eqm_callback, event_callback + ) return NashComputationResult( game=start.game, method="simpdiv", @@ -487,6 +601,10 @@ def simpdiv_solve( def ipa_solve( perturbation: libgbt.Game | libgbt.MixedStrategyProfileDouble, + eqm_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, + event_callback: Callable[ + [libgbt.IPAStepEvent | libgbt.IPATerminationEvent], None + ] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a game using :ref:`iterated polymatrix approximation `. @@ -502,6 +620,18 @@ def ipa_solve( Allow selection of the perturbation vector + eqm_callback : Callable[[MixedStrategyProfileDouble], None], optional + If specified, called with the equilibrium found, if any. + + .. versionadded:: 17.0.0 + + event_callback : Callable[[IPAStepEvent | IPATerminationEvent], None], optional + If specified, called with the state of the approximating strategy at + the end of each iteration (``IPAStepEvent``) and, possibly more than + once, with why an iteration terminated (``IPATerminationEvent``). + + .. versionadded:: 17.0.0 + Raises ------ ValueError @@ -534,7 +664,7 @@ def ipa_solve( rational=False, use_strategic=True, parameters={"perturbation": perturbation}, - equilibria=libgbt._ipa_strategy_solve(perturbation), + equilibria=libgbt._ipa_strategy_solve(perturbation, eqm_callback, event_callback), ) @@ -544,6 +674,16 @@ def gnm_solve( steps: int = 100, local_newton_interval: int = 3, local_newton_maxits: int = 10, + eqm_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, + event_callback: Callable[ + [ + libgbt.GNMPerturbationEvent + | libgbt.GNMStartEvent + | libgbt.GNMStepEvent + | libgbt.GNMTerminationEvent + ], + None, + ] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a game using :ref:`a global Newton method `. @@ -584,6 +724,24 @@ def gnm_solve( .. versionadded:: 16.2.0 + eqm_callback : Callable[[MixedStrategyProfileDouble], None], optional + If specified, called with each equilibrium as it is found, before + the method continues tracing the path. + + .. versionadded:: 17.0.0 + + event_callback : Callable[[GNMPerturbationEvent | GNMStartEvent | GNMStepEvent | \ +GNMTerminationEvent], None], optional + If specified, called with each event of the path-following run: the + perturbation vector traced from (``GNMPerturbationEvent``), the point + reached after following the perturbation ray (``GNMStartEvent``), + each predictor-corrector step taken while tracing the path + (``GNMStepEvent``), and, once, why the run terminated + (``GNMTerminationEvent``). Distinguish which occurred with + ``isinstance`` or ``match``. + + .. versionadded:: 17.0.0 + Raises ------ ValueError @@ -636,7 +794,8 @@ def gnm_solve( "local_newton_maxits": local_newton_maxits}, equilibria=libgbt._gnm_strategy_solve(perturbation, end_lambda, steps, - local_newton_interval, local_newton_maxits) + local_newton_interval, local_newton_maxits, + eqm_callback, event_callback) ) except RuntimeError as e: if "at least one nonzero" in str(e): @@ -671,7 +830,18 @@ def enumpoly_solve( stop_after: int | None = None, maxregret: float = 1.0e-8, max_rectangles: int = 20_000, - phcpack_path: pathlib.Path | str | None = None + phcpack_path: pathlib.Path | str | None = None, + eqm_callback: Callable[ + [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None + ] | None = None, + event_callback: Callable[ + [ + libgbt.EnumPolyCandidateSupportEvent + | libgbt.EnumPolySingularSupportEvent + | libgbt.EnumPolyBudgetExceededSupportEvent + ], + None, + ] | None = None, ) -> NashComputationResult: """:ref:`Compute Nash equilibria by enumerating all support profiles of strategies or actions, and for each support finding all totally-mixed equilibria of @@ -707,11 +877,38 @@ def enumpoly_solve( This argument specifies the path to the PHCpack executable. With this method, only enumeration on the strategic game is supported. + eqm_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional + If specified, called with each equilibrium as it is found, before + the method continues searching for any further equilibria. Not + available when `phcpack_path` is specified. + + .. versionadded:: 17.0.0 + + event_callback : Callable[[EnumPolyCandidateSupportEvent | EnumPolySingularSupportEvent \ +| EnumPolyBudgetExceededSupportEvent], None], optional + If specified, called with each support profile examined as a candidate + to contain a totally-mixed equilibrium (``EnumPolyCandidateSupportEvent``), + skipped because its system of equations was singular + (``EnumPolySingularSupportEvent``), or on which the rectangle budget was + exhausted before completing the search for roots + (``EnumPolyBudgetExceededSupportEvent``). Only available when solving + on the strategic representation; not available when `phcpack_path` is + specified. + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult The result represented as a ``NashComputationResult`` object. + Raises + ------ + ValueError + If `event_callback` is specified while solving on the extensive + representation, or either `eqm_callback` or `event_callback` is + specified along with `phcpack_path`. + Notes ----- PHCpack is available at https://homepages.math.uic.edu/~jan/PHCpack/phcpack.html @@ -740,6 +937,11 @@ def enumpoly_solve( "enumpoly_solve(): only solving on the strategic representation is " "supported by the PHCpack implementation" ) + if eqm_callback is not None or event_callback is not None: + raise ValueError( + "enumpoly_solve(): eqm_callback and event_callback cannot be used " + "with phcpack_path" + ) equilibria = nashphc.phcpack_solve(game, phcpack_path, maxregret) return NashComputationResult( game=game, @@ -752,9 +954,18 @@ def enumpoly_solve( ) if not game.is_tree or use_strategic: - equilibria = libgbt._enumpoly_strategy_solve(game, stop_after, maxregret, max_rectangles) + equilibria = libgbt._enumpoly_strategy_solve( + game, stop_after, maxregret, max_rectangles, eqm_callback, event_callback + ) else: - equilibria = libgbt._enumpoly_behavior_solve(game, stop_after, maxregret, max_rectangles) + if event_callback is not None: + raise ValueError( + "enumpoly_solve(): event_callback is only available when solving on the " + "strategic representation" + ) + equilibria = libgbt._enumpoly_behavior_solve( + game, stop_after, maxregret, max_rectangles, eqm_callback + ) return NashComputationResult( game=game, method="enumpoly", @@ -772,6 +983,9 @@ def logit_solve( maxregret: float = 1.0e-8, first_step: float = .03, max_accel: float = 1.1, + event_callback: Callable[ + [libgbt.LogitQREMixedStrategyProfile | libgbt.LogitQREMixedBehaviorProfile], None + ] | None = None, ) -> NashComputationResult: """Compute Nash equilibria of a game using :ref:`the logit quantal response equilibrium correspondence `. @@ -805,6 +1019,13 @@ def logit_solve( .. versionadded:: 16.2.0 + event_callback : Callable[[LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile], \ +None], optional + If specified, called with each point traced along the principal + branch on the way to the returned equilibrium. + + .. versionadded:: 17.0.0 + Returns ------- res : NashComputationResult @@ -817,9 +1038,13 @@ def logit_solve( if max_accel < 1.0: raise ValueError("logit_solve(): max_accel argument must be at least 1.0") if not game.is_tree or use_strategic: - equilibria = libgbt._logit_strategy_solve(game, maxregret, first_step, max_accel) + equilibria = libgbt._logit_strategy_solve( + game, maxregret, first_step, max_accel, event_callback + ) else: - equilibria = libgbt._logit_behavior_solve(game, maxregret, first_step, max_accel) + equilibria = libgbt._logit_behavior_solve( + game, maxregret, first_step, max_accel, event_callback + ) return NashComputationResult( game=game, method="logit", From 0c1e82593c98d6b5bc9feb085ffd0efb83b13bfe Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 25 Aug 2026 20:17:19 +0100 Subject: [PATCH 2/4] Add event monitoring in enumpoly on extensive games --- src/pygambit/callback.h | 54 +++++++++++++++++++++++++++++++++-------- src/pygambit/gambit.pxd | 3 ++- src/pygambit/nash.pxi | 48 ++++++++++++++++++++++++++++++------ src/pygambit/nash.py | 19 ++++++--------- 4 files changed, 94 insertions(+), 30 deletions(-) diff --git a/src/pygambit/callback.h b/src/pygambit/callback.h index 0971b245eb..7982c86b34 100644 --- a/src/pygambit/callback.h +++ b/src/pygambit/callback.h @@ -99,12 +99,18 @@ InvokeIPAStepEventCallback(PyObject *p_callback, int p_iteration, double p_zDiff, double p_sDiff); std::string InvokeIPATerminationEventCallback(PyObject *p_callback, int p_reason, std::string p_message); -std::string InvokeEnumPolyCandidateSupportEventCallback( +std::string InvokeEnumPolyStrategyCandidateSupportEventCallback( PyObject *p_callback, std::shared_ptr p_support); -std::string InvokeEnumPolySingularSupportEventCallback( +std::string InvokeEnumPolyStrategySingularSupportEventCallback( PyObject *p_callback, std::shared_ptr p_support); -std::string InvokeEnumPolyBudgetExceededSupportEventCallback( +std::string InvokeEnumPolyStrategyBudgetExceededSupportEventCallback( PyObject *p_callback, std::shared_ptr p_support); +std::string InvokeEnumPolyBehaviorCandidateSupportEventCallback( + PyObject *p_callback, std::shared_ptr p_support); +std::string InvokeEnumPolyBehaviorSingularSupportEventCallback( + PyObject *p_callback, std::shared_ptr p_support); +std::string InvokeEnumPolyBehaviorBudgetExceededSupportEventCallback( + PyObject *p_callback, std::shared_ptr p_support); namespace Gambit { @@ -391,10 +397,7 @@ inline Gambit::Nash::IPAEventCallbackType MakeIPAEventCallback(PyObject *p_callb /// dispatches to whichever Invoke*EventCallback trampoline matches the /// alternative held by the event, calling a Python callable with the /// corresponding pygambit event object. A null callback (Python `None`) -/// yields the solver's own no-op default. Only instantiated for -/// Support = StrategySupportProfile: pygambit does not yet expose a -/// BehaviorSupportProfile type, so the enumpoly_solve() behavior-form path -/// keeps using the solver's own null event callback, matching the GUI. +/// yields the solver's own no-op default. /// template Gambit::Nash::EnumPolyEventCallbackType MakeEnumPolyEventCallback(PyObject *p_callback); @@ -411,18 +414,18 @@ MakeEnumPolyEventCallback(PyObject *p_callback) [p_callback](const Event &event) { if constexpr (std::is_same_v>) { - Gambit::ThrowIfPythonError(InvokeEnumPolyCandidateSupportEventCallback( + Gambit::ThrowIfPythonError(InvokeEnumPolyStrategyCandidateSupportEventCallback( p_callback, std::make_shared(event.support))); } else if constexpr (std::is_same_v>) { - Gambit::ThrowIfPythonError(InvokeEnumPolySingularSupportEventCallback( + Gambit::ThrowIfPythonError(InvokeEnumPolyStrategySingularSupportEventCallback( p_callback, std::make_shared(event.support))); } else if constexpr (std::is_same_v>) { - Gambit::ThrowIfPythonError(InvokeEnumPolyBudgetExceededSupportEventCallback( + Gambit::ThrowIfPythonError(InvokeEnumPolyStrategyBudgetExceededSupportEventCallback( p_callback, std::make_shared(event.support))); } }, @@ -430,4 +433,35 @@ MakeEnumPolyEventCallback(PyObject *p_callback) }; } +template <> +inline Gambit::Nash::EnumPolyEventCallbackType +MakeEnumPolyEventCallback(PyObject *p_callback) +{ + if (!p_callback || p_callback == Py_None) { + return Gambit::Nash::NullEnumPolyEventCallback; + } + return [p_callback](const Gambit::Nash::EnumPolyEvent &p_event) { + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeEnumPolyBehaviorCandidateSupportEventCallback( + p_callback, std::make_shared(event.support))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeEnumPolyBehaviorSingularSupportEventCallback( + p_callback, std::make_shared(event.support))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeEnumPolyBehaviorBudgetExceededSupportEventCallback( + p_callback, std::make_shared(event.support))); + } + }, + p_event); + }; +} + #endif // GAMBIT_PYGAMBIT_CALLBACK_H diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 6acfdef0b6..fe51bae0e8 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -663,7 +663,8 @@ cdef extern from "solvers/enumpoly/enumpoly.h": StrategyCallbackType[double], EnumPolyEventCallbackType[c_StrategySupportProfile] ) except +RuntimeError stdlist[c_MixedBehaviorProfile[double]] EnumPolyBehaviorSolve( - c_Game, optional[size_t], float, size_t, BehaviorCallbackType[double] + c_Game, optional[size_t], float, size_t, + BehaviorCallbackType[double], EnumPolyEventCallbackType[c_BehaviorSupportProfile] ) except +RuntimeError cdef extern from "solvers/logit/logit.h": diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index c1df4ca7e7..2aae05a221 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -128,7 +128,7 @@ class EnumPolyCandidateSupportEvent: """Reports a support profile examined by :ref:`enumpoly ` as a candidate to contain a totally-mixed equilibrium. """ - support: StrategySupportProfile + support: StrategySupportProfile | BehaviorSupportProfile @dataclasses.dataclass(frozen=True) @@ -136,7 +136,7 @@ class EnumPolySingularSupportEvent: """Reports a support profile skipped by :ref:`enumpoly ` because the system of equations over it was singular. """ - support: StrategySupportProfile + support: StrategySupportProfile | BehaviorSupportProfile @dataclasses.dataclass(frozen=True) @@ -144,7 +144,7 @@ class EnumPolyBudgetExceededSupportEvent: """Reports a support profile on which :ref:`enumpoly ` exhausted its rectangle budget before completing the search for roots. """ - support: StrategySupportProfile + support: StrategySupportProfile | BehaviorSupportProfile cdef public string InvokeStrategyCallbackDouble( @@ -341,7 +341,7 @@ cdef public string InvokeIPATerminationEventCallback( return b"" -cdef public string InvokeEnumPolyCandidateSupportEventCallback( +cdef public string InvokeEnumPolyStrategyCandidateSupportEventCallback( callback, support: shared_ptr[c_StrategySupportProfile] ): try: @@ -351,7 +351,7 @@ cdef public string InvokeEnumPolyCandidateSupportEventCallback( return b"" -cdef public string InvokeEnumPolySingularSupportEventCallback( +cdef public string InvokeEnumPolyStrategySingularSupportEventCallback( callback, support: shared_ptr[c_StrategySupportProfile] ): try: @@ -361,7 +361,7 @@ cdef public string InvokeEnumPolySingularSupportEventCallback( return b"" -cdef public string InvokeEnumPolyBudgetExceededSupportEventCallback( +cdef public string InvokeEnumPolyStrategyBudgetExceededSupportEventCallback( callback, support: shared_ptr[c_StrategySupportProfile] ): try: @@ -373,6 +373,38 @@ cdef public string InvokeEnumPolyBudgetExceededSupportEventCallback( return b"" +cdef public string InvokeEnumPolyBehaviorCandidateSupportEventCallback( + callback, support: shared_ptr[c_BehaviorSupportProfile] +): + try: + callback(EnumPolyCandidateSupportEvent(support=BehaviorSupportProfile.wrap(support))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeEnumPolyBehaviorSingularSupportEventCallback( + callback, support: shared_ptr[c_BehaviorSupportProfile] +): + try: + callback(EnumPolySingularSupportEvent(support=BehaviorSupportProfile.wrap(support))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeEnumPolyBehaviorBudgetExceededSupportEventCallback( + callback, support: shared_ptr[c_BehaviorSupportProfile] +): + try: + callback( + EnumPolyBudgetExceededSupportEvent(support=BehaviorSupportProfile.wrap(support)) + ) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + @cython.cfunc def _convert_mspd( inlist: stdlist[c_MixedStrategyProfile[float]] @@ -622,13 +654,15 @@ def _enumpoly_behavior_solve( maxregret: float, max_rectangles: int, eqm_callback: object = None, + event_callback: object = None, ) -> list[MixedBehaviorProfileDouble]: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after return _convert_mbpd(EnumPolyBehaviorSolve( game.game, c_stop_after, maxregret, max_rectangles, - MakeBehaviorCallback[double](eqm_callback) + MakeBehaviorCallback[double](eqm_callback), + MakeEnumPolyEventCallback[c_BehaviorSupportProfile](event_callback) )) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index e5f7b9ed52..678f9d4dc1 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -891,9 +891,10 @@ def enumpoly_solve( skipped because its system of equations was singular (``EnumPolySingularSupportEvent``), or on which the rectangle budget was exhausted before completing the search for roots - (``EnumPolyBudgetExceededSupportEvent``). Only available when solving - on the strategic representation; not available when `phcpack_path` is - specified. + (``EnumPolyBudgetExceededSupportEvent``). The ``support`` carried by + each event is a ``StrategySupportProfile`` or ``BehaviorSupportProfile`` + according to which representation is being solved on. Not available + when `phcpack_path` is specified. .. versionadded:: 17.0.0 @@ -905,9 +906,8 @@ def enumpoly_solve( Raises ------ ValueError - If `event_callback` is specified while solving on the extensive - representation, or either `eqm_callback` or `event_callback` is - specified along with `phcpack_path`. + If either `eqm_callback` or `event_callback` is specified along with + `phcpack_path`. Notes ----- @@ -958,13 +958,8 @@ def enumpoly_solve( game, stop_after, maxregret, max_rectangles, eqm_callback, event_callback ) else: - if event_callback is not None: - raise ValueError( - "enumpoly_solve(): event_callback is only available when solving on the " - "strategic representation" - ) equilibria = libgbt._enumpoly_behavior_solve( - game, stop_after, maxregret, max_rectangles, eqm_callback + game, stop_after, maxregret, max_rectangles, eqm_callback, event_callback ) return NashComputationResult( game=game, From 3bd7ab851c63effe225e927fe8e6980eb7725669 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 25 Aug 2026 20:26:22 +0100 Subject: [PATCH 3/4] Name equilibrium hook `nash_callback` --- src/pygambit/nash.pxi | 76 ++++++++++++++++----------------- src/pygambit/nash.py | 98 +++++++++++++++++++++---------------------- 2 files changed, 87 insertions(+), 87 deletions(-) diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index 2aae05a221..1a681f8ffc 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -438,121 +438,121 @@ def _convert_mbpr( def _enumpure_strategy_solve( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedStrategyProfile[c_Rational]]: return _convert_mspr( - EnumPureStrategySolve(game.game, MakeStrategyCallback[c_Rational](eqm_callback)) + EnumPureStrategySolve(game.game, MakeStrategyCallback[c_Rational](nash_callback)) ) def _enumpure_agent_solve( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedBehaviorProfileRational]: return _convert_mbpr( - EnumPureAgentSolve(game.game, MakeBehaviorCallback[c_Rational](eqm_callback)) + EnumPureAgentSolve(game.game, MakeBehaviorCallback[c_Rational](nash_callback)) ) def _enummixed_strategy_solve_double( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedStrategyProfileDouble]: return _convert_mspd( - EnumMixedStrategySolve[double](game.game, MakeStrategyCallback[double](eqm_callback)) + EnumMixedStrategySolve[double](game.game, MakeStrategyCallback[double](nash_callback)) ) def _enummixed_strategy_solve_rational( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedStrategyProfileRational]: return _convert_mspr( EnumMixedStrategySolve[c_Rational]( - game.game, MakeStrategyCallback[c_Rational](eqm_callback) + game.game, MakeStrategyCallback[c_Rational](nash_callback) ) ) def _lcp_behavior_solve_double( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedBehaviorProfileDouble]: return _convert_mbpd( - LcpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](eqm_callback)) + LcpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](nash_callback)) ) def _lcp_behavior_solve_rational( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedBehaviorProfileRational]: return _convert_mbpr( - LcpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](eqm_callback)) + LcpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](nash_callback)) ) def _lcp_strategy_solve_double( - game: Game, stop_after, max_depth: int, eqm_callback: object = None + game: Game, stop_after, max_depth: int, nash_callback: object = None ) -> list[MixedStrategyProfileDouble]: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after return _convert_mspd( LcpStrategySolve[double]( - game.game, c_stop_after, max_depth, MakeStrategyCallback[double](eqm_callback) + game.game, c_stop_after, max_depth, MakeStrategyCallback[double](nash_callback) ) ) def _lcp_strategy_solve_rational( - game: Game, stop_after, max_depth: int, eqm_callback: object = None + game: Game, stop_after, max_depth: int, nash_callback: object = None ) -> list[MixedStrategyProfileRational]: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after return _convert_mspr( LcpStrategySolve[c_Rational]( - game.game, c_stop_after, max_depth, MakeStrategyCallback[c_Rational](eqm_callback) + game.game, c_stop_after, max_depth, MakeStrategyCallback[c_Rational](nash_callback) ) ) def _lp_behavior_solve_double( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedBehaviorProfileDouble]: return _convert_mbpd( - LpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](eqm_callback)) + LpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](nash_callback)) ) def _lp_behavior_solve_rational( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedBehaviorProfileRational]: return _convert_mbpr( - LpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](eqm_callback)) + LpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](nash_callback)) ) def _lp_strategy_solve_double( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedStrategyProfileDouble]: return _convert_mspd( - LpStrategySolve[double](game.game, MakeStrategyCallback[double](eqm_callback)) + LpStrategySolve[double](game.game, MakeStrategyCallback[double](nash_callback)) ) def _lp_strategy_solve_rational( - game: Game, eqm_callback: object = None + game: Game, nash_callback: object = None ) -> list[MixedStrategyProfileRational]: return _convert_mspr( - LpStrategySolve[c_Rational](game.game, MakeStrategyCallback[c_Rational](eqm_callback)) + LpStrategySolve[c_Rational](game.game, MakeStrategyCallback[c_Rational](nash_callback)) ) def _liap_strategy_solve(start: MixedStrategyProfileDouble, maxregret: float, maxiter: int, - eqm_callback: object = None, + nash_callback: object = None, event_callback: object = None) -> list[MixedStrategyProfileDouble]: return _convert_mspd(LiapStrategySolve( deref(start.profile), maxregret, maxiter, - MakeStrategyCallback[double](eqm_callback), + MakeStrategyCallback[double](nash_callback), MakeLiapEventCallback[c_MixedStrategyProfile[double]](event_callback) )) @@ -560,33 +560,33 @@ def _liap_strategy_solve(start: MixedStrategyProfileDouble, def _liap_behavior_solve(start: MixedBehaviorProfileDouble, maxregret: float, maxiter: int, - eqm_callback: object = None, + nash_callback: object = None, event_callback: object = None) -> list[MixedBehaviorProfileDouble]: return _convert_mbpd(LiapAgentSolve( deref(start.profile), maxregret, maxiter, - MakeBehaviorCallback[double](eqm_callback), + MakeBehaviorCallback[double](nash_callback), MakeLiapEventCallback[c_MixedBehaviorProfile[double]](event_callback) )) def _simpdiv_strategy_solve( start: MixedStrategyProfileRational, maxregret: Rational, gridstep: int, leash: int, - eqm_callback: object = None, event_callback: object = None + nash_callback: object = None, event_callback: object = None ) -> list[MixedStrategyProfileRational]: return _convert_mspr(SimpdivStrategySolve( deref(start.profile), to_rational(str(maxregret).encode("ascii")), gridstep, leash, - MakeStrategyCallback[c_Rational](eqm_callback), MakeSimpdivEventCallback(event_callback) + MakeStrategyCallback[c_Rational](nash_callback), MakeSimpdivEventCallback(event_callback) )) def _ipa_strategy_solve( - pert: MixedStrategyProfileDouble, eqm_callback: object = None, + pert: MixedStrategyProfileDouble, nash_callback: object = None, event_callback: object = None ) -> list[MixedStrategyProfileDouble]: try: return _convert_mspd(IPAStrategySolve( deref(pert.profile), - MakeStrategyCallback[double](eqm_callback), MakeIPAEventCallback(event_callback) + MakeStrategyCallback[double](nash_callback), MakeIPAEventCallback(event_callback) )) except RuntimeError as e: if "does not have unique maximizer" in str(e): @@ -600,13 +600,13 @@ def _gnm_strategy_solve( steps: int, local_newton_interval: int, local_newton_maxits: int, - eqm_callback: object = None, + nash_callback: object = None, event_callback: object = None, ) -> list[MixedStrategyProfileDouble]: try: return _convert_mspd(GNMStrategySolve( deref(pert.profile), end_lambda, steps, local_newton_interval, local_newton_maxits, - MakeStrategyCallback[double](eqm_callback), MakeGNMEventCallback(event_callback) + MakeStrategyCallback[double](nash_callback), MakeGNMEventCallback(event_callback) )) except RuntimeError as e: if "does not have unique maximizer" in str(e): @@ -635,7 +635,7 @@ def _enumpoly_strategy_solve( stop_after, maxregret: float, max_rectangles: int, - eqm_callback: object = None, + nash_callback: object = None, event_callback: object = None, ) -> list[MixedStrategyProfileDouble]: cdef optional[size_t] c_stop_after @@ -643,7 +643,7 @@ def _enumpoly_strategy_solve( c_stop_after = stop_after return _convert_mspd(EnumPolyStrategySolve( game.game, c_stop_after, maxregret, max_rectangles, - MakeStrategyCallback[double](eqm_callback), + MakeStrategyCallback[double](nash_callback), MakeEnumPolyEventCallback[c_StrategySupportProfile](event_callback) )) @@ -653,7 +653,7 @@ def _enumpoly_behavior_solve( stop_after, maxregret: float, max_rectangles: int, - eqm_callback: object = None, + nash_callback: object = None, event_callback: object = None, ) -> list[MixedBehaviorProfileDouble]: cdef optional[size_t] c_stop_after @@ -661,7 +661,7 @@ def _enumpoly_behavior_solve( c_stop_after = stop_after return _convert_mbpd(EnumPolyBehaviorSolve( game.game, c_stop_after, maxregret, max_rectangles, - MakeBehaviorCallback[double](eqm_callback), + MakeBehaviorCallback[double](nash_callback), MakeEnumPolyEventCallback[c_BehaviorSupportProfile](event_callback) )) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 678f9d4dc1..017049e962 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -70,7 +70,7 @@ class NashComputationResult: def enumpure_solve( game: libgbt.Game, - eqm_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, + nash_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, ) -> NashComputationResult: """Compute all :ref:`pure-strategy Nash equilibria ` of game. @@ -81,14 +81,14 @@ def enumpure_solve( .. versionchanged:: 17.0.0 - Added `eqm_callback` parameter. + Added `nash_callback` parameter. Parameters ---------- game : Game The game to compute equilibria in. - eqm_callback : Callable[[MixedStrategyProfileRational], None], optional + nash_callback : Callable[[MixedStrategyProfileRational], None], optional If specified, called with each equilibrium as it is found, before the method continues searching for any further equilibria. @@ -106,13 +106,13 @@ def enumpure_solve( method="enumpure", rational=True, use_strategic=True, - equilibria=libgbt._enumpure_strategy_solve(game, eqm_callback) + equilibria=libgbt._enumpure_strategy_solve(game, nash_callback) ) def enumpure_agent_solve( game: libgbt.Game, - eqm_callback: Callable[[libgbt.MixedBehaviorProfileRational], None] | None = None, + nash_callback: Callable[[libgbt.MixedBehaviorProfileRational], None] | None = None, ) -> NashComputationResult: """Compute all :ref:`pure-strategy agent Nash equilibria ` of game. @@ -122,14 +122,14 @@ def enumpure_agent_solve( .. versionchanged:: 17.0.0 - Added `eqm_callback` parameter. + Added `nash_callback` parameter. Parameters ---------- game : Game The game to compute agent-Nash equilibria in. - eqm_callback : Callable[[MixedBehaviorProfileRational], None], optional + nash_callback : Callable[[MixedBehaviorProfileRational], None], optional If specified, called with each equilibrium as it is found, before the method continues searching for any further equilibria. @@ -147,7 +147,7 @@ def enumpure_agent_solve( method="enumpure-agent", rational=True, use_strategic=False, - equilibria=libgbt._enumpure_agent_solve(game, eqm_callback) + equilibria=libgbt._enumpure_agent_solve(game, nash_callback) ) @@ -155,7 +155,7 @@ def enummixed_solve( game: libgbt.Game, rational: bool = True, lrsnash_path: pathlib.Path | str | None = None, - eqm_callback: Callable[ + nash_callback: Callable[ [libgbt.MixedStrategyProfile], None ] | None = None, ) -> NashComputationResult: @@ -177,7 +177,7 @@ def enummixed_solve( .. versionadded:: 16.3.0 - eqm_callback : Callable[[MixedStrategyProfile], None], optional + nash_callback : Callable[[MixedStrategyProfile], None], optional If specified, called with each equilibrium as it is found, before the method continues searching for any further equilibria. Not available when `lrsnash_path` is specified. @@ -195,16 +195,16 @@ def enummixed_solve( If game has more than two players. ValueError - If both `lrsnash_path` and `eqm_callback` are specified. + If both `lrsnash_path` and `nash_callback` are specified. Notes ----- `lrsnash` is part of `lrslib`, available at http://cgm.cs.mcgill.ca/~avis/C/lrs.html """ if lrsnash_path is not None: - if eqm_callback is not None: + if nash_callback is not None: raise ValueError( - "enummixed_solve(): eqm_callback cannot be used with lrsnash_path" + "enummixed_solve(): nash_callback cannot be used with lrsnash_path" ) equilibria = nashlrs.lrsnash_solve(game, lrsnash_path=lrsnash_path) return NashComputationResult( @@ -216,9 +216,9 @@ def enummixed_solve( equilibria=equilibria, ) if rational: - equilibria = libgbt._enummixed_strategy_solve_rational(game, eqm_callback) + equilibria = libgbt._enummixed_strategy_solve_rational(game, nash_callback) else: - equilibria = libgbt._enummixed_strategy_solve_double(game, eqm_callback) + equilibria = libgbt._enummixed_strategy_solve_double(game, nash_callback) return NashComputationResult( game=game, method="enummixed", @@ -234,7 +234,7 @@ def lcp_solve( use_strategic: bool = False, stop_after: int | None = None, max_depth: int | None = None, - eqm_callback: Callable[ + nash_callback: Callable[ [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None ] | None = None, ) -> NashComputationResult: @@ -263,7 +263,7 @@ def lcp_solve( If specified, will limit the recursive search, but may result in some accessible equilibria not being found. - eqm_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional + nash_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional If specified, called with each equilibrium as it is found, before the method continues searching for any further equilibria. @@ -302,16 +302,16 @@ def lcp_solve( if not game.is_tree or use_strategic: if rational: equilibria = libgbt._lcp_strategy_solve_rational( - game, stop_after, max_depth or 0, eqm_callback + game, stop_after, max_depth or 0, nash_callback ) else: equilibria = libgbt._lcp_strategy_solve_double( - game, stop_after, max_depth or 0, eqm_callback + game, stop_after, max_depth or 0, nash_callback ) elif rational: - equilibria = libgbt._lcp_behavior_solve_rational(game, eqm_callback) + equilibria = libgbt._lcp_behavior_solve_rational(game, nash_callback) else: - equilibria = libgbt._lcp_behavior_solve_double(game, eqm_callback) + equilibria = libgbt._lcp_behavior_solve_double(game, nash_callback) return NashComputationResult( game=game, method="lcp", @@ -326,7 +326,7 @@ def lp_solve( game: libgbt.Game, rational: bool = True, use_strategic: bool = False, - eqm_callback: Callable[ + nash_callback: Callable[ [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None ] | None = None, ) -> NashComputationResult: @@ -346,7 +346,7 @@ def lp_solve( Whether to use the strategic form. If `True`, always uses the strategic representation even if the game's native representation is extensive. - eqm_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional + nash_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional If specified, called with each equilibrium as it is found, before the method continues searching for any further equilibria. @@ -364,13 +364,13 @@ def lp_solve( """ if not game.is_tree or use_strategic: if rational: - equilibria = libgbt._lp_strategy_solve_rational(game, eqm_callback) + equilibria = libgbt._lp_strategy_solve_rational(game, nash_callback) else: - equilibria = libgbt._lp_strategy_solve_double(game, eqm_callback) + equilibria = libgbt._lp_strategy_solve_double(game, nash_callback) elif rational: - equilibria = libgbt._lp_behavior_solve_rational(game, eqm_callback) + equilibria = libgbt._lp_behavior_solve_rational(game, nash_callback) else: - equilibria = libgbt._lp_behavior_solve_double(game, eqm_callback) + equilibria = libgbt._lp_behavior_solve_double(game, nash_callback) return NashComputationResult( game=game, method="lp", @@ -384,7 +384,7 @@ def liap_solve( start: libgbt.MixedStrategyProfileDouble, maxregret: float = 1.0e-4, maxiter: int = 1000, - eqm_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, + nash_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, event_callback: Callable[ [libgbt.LiapStartEvent | libgbt.LiapEndEvent], None ] | None = None, @@ -422,7 +422,7 @@ def liap_solve( .. versionadded: 16.2.0 - eqm_callback : Callable[[MixedStrategyProfileDouble], None], optional + nash_callback : Callable[[MixedStrategyProfileDouble], None], optional If specified, called with the equilibrium found, if any. .. versionadded:: 17.0.0 @@ -443,7 +443,7 @@ def liap_solve( raise ValueError("liap_solve(): maxregret argument must be positive") equilibria = libgbt._liap_strategy_solve( start, maxregret=maxregret, maxiter=maxiter, - eqm_callback=eqm_callback, event_callback=event_callback + nash_callback=nash_callback, event_callback=event_callback ) return NashComputationResult( game=start.game, @@ -459,7 +459,7 @@ def liap_agent_solve( start: libgbt.MixedBehaviorProfileDouble, maxregret: float = 1.0e-4, maxiter: int = 1000, - eqm_callback: Callable[[libgbt.MixedBehaviorProfileDouble], None] | None = None, + nash_callback: Callable[[libgbt.MixedBehaviorProfileDouble], None] | None = None, event_callback: Callable[ [libgbt.LiapStartEvent | libgbt.LiapEndEvent], None ] | None = None, @@ -487,7 +487,7 @@ def liap_agent_solve( maxiter : int, default 1000 Maximum number of iterations in function minimization. - eqm_callback : Callable[[MixedBehaviorProfileDouble], None], optional + nash_callback : Callable[[MixedBehaviorProfileDouble], None], optional If specified, called with the equilibrium found, if any. .. versionadded:: 17.0.0 @@ -508,7 +508,7 @@ def liap_agent_solve( raise ValueError("liap_solve(): maxregret argument must be positive") equilibria = libgbt._liap_behavior_solve( start, maxregret=maxregret, maxiter=maxiter, - eqm_callback=eqm_callback, event_callback=event_callback + nash_callback=nash_callback, event_callback=event_callback ) return NashComputationResult( game=start.game, @@ -525,7 +525,7 @@ def simpdiv_solve( maxregret: libgbt.Rational | None = None, refine: int = 2, leash: int | None = None, - eqm_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, + nash_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, event_callback: Callable[ [libgbt.SimpdivStartEvent | libgbt.SimpdivRefinementEvent], None ] | None = None, @@ -562,7 +562,7 @@ def simpdiv_solve( may explore. This trades off the possibility of finding an equilibrium more quickly by giving up the guarantee than an equilibrium will necessarily be found. - eqm_callback : Callable[[MixedStrategyProfileRational], None], optional + nash_callback : Callable[[MixedStrategyProfileRational], None], optional If specified, called with the equilibrium found, if any. .. versionadded:: 17.0.0 @@ -587,7 +587,7 @@ def simpdiv_solve( elif maxregret < libgbt.Rational(0): raise ValueError("simpdiv_solve(): maxregret must be positive") equilibria = libgbt._simpdiv_strategy_solve( - start, maxregret, refine, leash or 0, eqm_callback, event_callback + start, maxregret, refine, leash or 0, nash_callback, event_callback ) return NashComputationResult( game=start.game, @@ -601,7 +601,7 @@ def simpdiv_solve( def ipa_solve( perturbation: libgbt.Game | libgbt.MixedStrategyProfileDouble, - eqm_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, + nash_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, event_callback: Callable[ [libgbt.IPAStepEvent | libgbt.IPATerminationEvent], None ] | None = None, @@ -620,7 +620,7 @@ def ipa_solve( Allow selection of the perturbation vector - eqm_callback : Callable[[MixedStrategyProfileDouble], None], optional + nash_callback : Callable[[MixedStrategyProfileDouble], None], optional If specified, called with the equilibrium found, if any. .. versionadded:: 17.0.0 @@ -664,7 +664,7 @@ def ipa_solve( rational=False, use_strategic=True, parameters={"perturbation": perturbation}, - equilibria=libgbt._ipa_strategy_solve(perturbation, eqm_callback, event_callback), + equilibria=libgbt._ipa_strategy_solve(perturbation, nash_callback, event_callback), ) @@ -674,7 +674,7 @@ def gnm_solve( steps: int = 100, local_newton_interval: int = 3, local_newton_maxits: int = 10, - eqm_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, + nash_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None, event_callback: Callable[ [ libgbt.GNMPerturbationEvent @@ -724,7 +724,7 @@ def gnm_solve( .. versionadded:: 16.2.0 - eqm_callback : Callable[[MixedStrategyProfileDouble], None], optional + nash_callback : Callable[[MixedStrategyProfileDouble], None], optional If specified, called with each equilibrium as it is found, before the method continues tracing the path. @@ -795,7 +795,7 @@ def gnm_solve( equilibria=libgbt._gnm_strategy_solve(perturbation, end_lambda, steps, local_newton_interval, local_newton_maxits, - eqm_callback, event_callback) + nash_callback, event_callback) ) except RuntimeError as e: if "at least one nonzero" in str(e): @@ -831,7 +831,7 @@ def enumpoly_solve( maxregret: float = 1.0e-8, max_rectangles: int = 20_000, phcpack_path: pathlib.Path | str | None = None, - eqm_callback: Callable[ + nash_callback: Callable[ [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None ] | None = None, event_callback: Callable[ @@ -877,7 +877,7 @@ def enumpoly_solve( This argument specifies the path to the PHCpack executable. With this method, only enumeration on the strategic game is supported. - eqm_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional + nash_callback : Callable[[MixedStrategyProfile | MixedBehaviorProfile], None], optional If specified, called with each equilibrium as it is found, before the method continues searching for any further equilibria. Not available when `phcpack_path` is specified. @@ -906,7 +906,7 @@ def enumpoly_solve( Raises ------ ValueError - If either `eqm_callback` or `event_callback` is specified along with + If either `nash_callback` or `event_callback` is specified along with `phcpack_path`. Notes @@ -937,9 +937,9 @@ def enumpoly_solve( "enumpoly_solve(): only solving on the strategic representation is " "supported by the PHCpack implementation" ) - if eqm_callback is not None or event_callback is not None: + if nash_callback is not None or event_callback is not None: raise ValueError( - "enumpoly_solve(): eqm_callback and event_callback cannot be used " + "enumpoly_solve(): nash_callback and event_callback cannot be used " "with phcpack_path" ) equilibria = nashphc.phcpack_solve(game, phcpack_path, maxregret) @@ -955,11 +955,11 @@ def enumpoly_solve( if not game.is_tree or use_strategic: equilibria = libgbt._enumpoly_strategy_solve( - game, stop_after, maxregret, max_rectangles, eqm_callback, event_callback + game, stop_after, maxregret, max_rectangles, nash_callback, event_callback ) else: equilibria = libgbt._enumpoly_behavior_solve( - game, stop_after, maxregret, max_rectangles, eqm_callback, event_callback + game, stop_after, maxregret, max_rectangles, nash_callback, event_callback ) return NashComputationResult( game=game, From 144a6c906ce432cff3266ec0856e249ee3a7e9ff Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 25 Aug 2026 21:36:33 +0100 Subject: [PATCH 4/4] ChangeLog --- ChangeLog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog b/ChangeLog index 7e234c25f3..19c2093797 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,8 @@ ### Added - Added `BehaviorSupportProfile` to `pygambit`. +- Added callback functions to Nash solvers, allowing calling code to take actions when an equilibrium + is found or another solver event is emitted. ### Changed - Revised `StrategySupportProfile` to expose a slimmer set of operations, and