diff --git a/ChangeLog b/ChangeLog index b33fa76955..138f7bf7a9 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,6 +3,8 @@ ### Changed - Refined the perturbation approach in `path.cc` (logit and hp solvers) to better handle paths on games with bifurcations. (#492) +- Nash solver algorithms now return a method-specific `Result` class, rather than a generic class, + with potentially richer information about the performance of the algorithm. ## [17.0.0-beta.1] - 2026-09-08 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e0c798531c..f333a9c267 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -328,7 +328,6 @@ Computation of Nash equilibria .. autosummary:: :toctree: api/ - NashComputationResult enumpure_solve enumpure_agent_solve enummixed_solve @@ -343,6 +342,25 @@ Computation of Nash equilibria gnm_solve hp_solve +Each of these returns its own result type, documented below: + +.. autosummary:: + :toctree: api/ + + NashResultBase + EnumPureResult + EnumMixedResult + EnumPolyResult + LpResult + LcpStrategyResult + LcpBehaviorResult + LiapResult + LogitResult + SimpdivResult + IPAResult + GNMResult + HPResult + Computation of quantal response equilibria ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/tutorials/01_quickstart.ipynb b/doc/tutorials/01_quickstart.ipynb index da7e16aa37..385081f21d 100644 --- a/doc/tutorials/01_quickstart.ipynb +++ b/doc/tutorials/01_quickstart.ipynb @@ -191,7 +191,7 @@ "We can use Gambit to compute the Nash equilibria for our Prisoner's Dilemma game in a single line of code. A Nash equilibrium describes a profile of strategies, one for each player, such that each player is maximizing their payoff given the strategies the other players are adopting.\n", "\n", "For a two-player Normal-form game, let's use `enumpure_solve` to search for a pure-strategy Nash equilibria.\n", - "The returned object will be a `NashComputationResult`." + "The returned object will be an `EnumPureResult`." ] }, { diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 55817e598a..63d16b4e3f 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -278,9 +278,9 @@ "id": "e5946077", "metadata": {}, "source": [ - "The result of the calculation is returned as a `NashComputationResult` object.\n", + "The result of the calculation is returned as an `LcpBehaviorResult` object.\n", "\n", - "The set of equilibria found is reported in `NashComputationResult.equilibria`; in this case, this is a list of `MixedBehaviorProfile`'s.\n", + "The equilibrium found is reported in `LcpBehaviorResult.equilibrium`; in this case, this is a single `MixedBehaviorProfile`.\n", "\n", "For one-card poker, we expect to find a single equilibrium (one `MixedBehaviorProfile`):" ] @@ -292,8 +292,8 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"Number of equilibria found:\", len(result.equilibria))\n", - "eqm = result.equilibria[0]" + "print(\"Equilibrium found:\", result.equilibrium is not None)\n", + "eqm = result.equilibrium" ] }, { @@ -650,7 +650,7 @@ "outputs": [], "source": [ "logit_solve_result = gbt.nash.logit_solve(g, maxregret=1e-8)\n", - "len(logit_solve_result.equilibria)" + "logit_solve_result.success" ] }, { @@ -660,7 +660,7 @@ "metadata": {}, "outputs": [], "source": [ - "ls_eqm = logit_solve_result.equilibria[0]\n", + "ls_eqm = logit_solve_result.equilibrium\n", "ls_eqm.max_regret()" ] }, @@ -701,7 +701,7 @@ "outputs": [], "source": [ "(\n", - " gbt.nash.logit_solve(g, maxregret=1e-4).equilibria[0]\n", + " gbt.nash.logit_solve(g, maxregret=1e-4).equilibrium\n", " .max_regret() / (g.max_payoff - g.min_payoff)\n", ")" ] @@ -755,7 +755,7 @@ "source": [ "(\n", " gbt.nash.liap_solve(g.mixed_strategy_profile(), maxregret=1e-1)\n", - " .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n", + " .equilibrium.max_regret() / (g.max_payoff - g.min_payoff)\n", ")" ] }, @@ -773,7 +773,7 @@ "id": "a892dc2b", "metadata": {}, "outputs": [], - "source": "for label in g.get_outcomes():\n payoffs = g.get_outcome_payoffs(label)\n g.set_outcome_payoffs(label, {player: value * 2 for player, value in payoffs})\n\n(\n gbt.nash.liap_solve(g.mixed_strategy_profile(), maxregret=1e-1)\n .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n)" + "source": "for label in g.get_outcomes():\n payoffs = g.get_outcome_payoffs(label)\n g.set_outcome_payoffs(label, {player: value * 2 for player, value in payoffs})\n\n(\n gbt.nash.liap_solve(g.mixed_strategy_profile(), maxregret=1e-1)\n .equilibrium.max_regret() / (g.max_payoff - g.min_payoff)\n)" }, { "cell_type": "markdown", diff --git a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb index 0785585928..4dcb49b1e2 100644 --- a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb +++ b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb @@ -158,7 +158,7 @@ "metadata": {}, "outputs": [], "source": [ - "candidate_eq = gbt.nash.liap_solve(start=starting_profile_double).equilibria[0]\n", + "candidate_eq = gbt.nash.liap_solve(start=starting_profile_double).equilibrium\n", "print(candidate_eq)" ] }, diff --git a/doc/tutorials/advanced_tutorials/quantal_response.ipynb b/doc/tutorials/advanced_tutorials/quantal_response.ipynb index feea3a27c1..70f2446939 100644 --- a/doc/tutorials/advanced_tutorials/quantal_response.ipynb +++ b/doc/tutorials/advanced_tutorials/quantal_response.ipynb @@ -33,7 +33,7 @@ " [[0, 1.1141], [1.1141, 0]],\n", " title=\"Ochs (1995) asymmetric matching pennies as transformed in McKelvey-Palfrey (1995)\"\n", ")\n", - "gbt.nash.logit_solve(g).equilibria[0]" + "gbt.nash.logit_solve(g).equilibrium" ] }, { diff --git a/doc/tutorials/advanced_tutorials/starting_points.ipynb b/doc/tutorials/advanced_tutorials/starting_points.ipynb index 491667abf2..be74ab7c79 100644 --- a/doc/tutorials/advanced_tutorials/starting_points.ipynb +++ b/doc/tutorials/advanced_tutorials/starting_points.ipynb @@ -65,7 +65,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt.nash.liap_solve(centroid_start).equilibria[0]" + "gbt.nash.liap_solve(centroid_start).equilibrium" ] }, { @@ -97,7 +97,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt.nash.liap_solve(new_start).equilibria[0]" + "gbt.nash.liap_solve(new_start).equilibrium" ] }, { @@ -126,7 +126,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt.nash.liap_solve(random_start).equilibria[0]" + "gbt.nash.liap_solve(random_start).equilibrium" ] }, { @@ -187,7 +187,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt.nash.simpdiv_solve(rsp).equilibria[0]" + "gbt.nash.simpdiv_solve(rsp).equilibrium" ] }, { @@ -208,7 +208,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt.nash.simpdiv_solve(rsp1).equilibria[0]" + "gbt.nash.simpdiv_solve(rsp1).equilibrium" ] }, { @@ -229,7 +229,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt.nash.simpdiv_solve(rsp2).equilibria[0]" + "gbt.nash.simpdiv_solve(rsp2).equilibrium" ] } ], diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index e05408d767..735f6c08e3 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -438,7 +438,7 @@ "outputs": [], "source": [ "gbt_hanabi_game = gbt.catalog.generate_openspiel(\"tiny_hanabi\")\n", - "eqm = gbt.nash.lcp_solve(gbt_hanabi_game).equilibria[0]" + "eqm = gbt.nash.lcp_solve(gbt_hanabi_game).equilibrium" ] }, { diff --git a/src/pygambit/cli/enummixed.py b/src/pygambit/cli/enummixed.py index edd95189d4..989d6b6e90 100644 --- a/src/pygambit/cli/enummixed.py +++ b/src/pygambit/cli/enummixed.py @@ -72,7 +72,7 @@ def render(profile) -> None: cliques=cliques, ) if cliques: - for index, clique in enumerate(result.parameters["cliques"], start=1): + for index, clique in enumerate(result.cliques, start=1): label = f"convex-{index}" for profile in clique: click.echo(render_profile_csv(profile, label, decimals or 0)) diff --git a/src/pygambit/cli/hp.py b/src/pygambit/cli/hp.py index 41acc7890c..add1c43656 100644 --- a/src/pygambit/cli/hp.py +++ b/src/pygambit/cli/hp.py @@ -119,8 +119,8 @@ def render_event(event: gbt.HPStepEvent) -> None: if verbose: click.echo(render_profile_csv(prior, "prior", decimals)) result = gbt.nash.hp_solve(prior, maxregret=maxregret, event_callback=render_event) - for eq in result.equilibria: - click.echo(render_profile_csv(eq, "NE", decimals)) + if result.equilibrium is not None: + click.echo(render_profile_csv(result.equilibrium, "NE", decimals)) if __name__ == "__main__": diff --git a/src/pygambit/cli/logit.py b/src/pygambit/cli/logit.py index 9351244145..bc4e90a501 100644 --- a/src/pygambit/cli/logit.py +++ b/src/pygambit/cli/logit.py @@ -193,7 +193,7 @@ def stream(qre) -> None: first_step=first_step, max_accel=max_accel, ) - click.echo(render_profile_csv(result.equilibria[-1], "NE", decimals, fixed=False)) + click.echo(render_profile_csv(result.equilibrium, "NE", decimals, fixed=False)) return result = gbt.nash.logit_solve( @@ -204,7 +204,7 @@ def stream(qre) -> None: max_accel=max_accel, event_callback=stream, ) - click.echo(render_profile_csv(result.equilibria[-1], "NE", decimals, fixed=False)) + click.echo(render_profile_csv(result.equilibrium, "NE", decimals, fixed=False)) if __name__ == "__main__": diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index bbee7d3154..8855dc52da 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -550,73 +550,156 @@ cdef extern from "callback.h": EnumPolyEventCallbackType[T] MakeEnumPolyEventCallback[T](object) cdef extern from "solvers/enumpure/enumpure.h": - stdlist[c_MixedStrategyProfile[c_Rational]] EnumPureStrategySolve( + cdef cppclass c_EnumPureStrategyResult "Gambit::Nash::EnumPureStrategyResult": + stdlist[c_MixedStrategyProfile[c_Rational]] equilibria + bool success + cdef cppclass c_EnumPureAgentResult "Gambit::Nash::EnumPureAgentResult": + stdlist[c_MixedBehaviorProfile[c_Rational]] equilibria + bool success + c_EnumPureStrategyResult EnumPureStrategySolve( c_Game, StrategyCallbackType[c_Rational] ) except +RuntimeError - stdlist[c_MixedBehaviorProfile[c_Rational]] EnumPureAgentSolve( + c_EnumPureAgentResult EnumPureAgentSolve( c_Game, BehaviorCallbackType[c_Rational] ) except +RuntimeError cdef extern from "solvers/enummixed/enummixed.h": - stdlist[c_MixedStrategyProfile[T]] EnumMixedStrategySolve[T]( + cdef cppclass c_EnumMixedStrategyResult "Gambit::Nash::EnumMixedStrategyResult"[T]: + stdlist[c_MixedStrategyProfile[T]] equilibria + bool success + c_EnumMixedStrategyResult[T] EnumMixedStrategySolve[T]( c_Game, StrategyCallbackType[T] ) except +RuntimeError cdef extern from "solvers/lcp/lcp.h": - stdlist[c_MixedStrategyProfile[T]] LcpStrategySolve[T]( + cdef cppclass c_LcpStrategyResult "Gambit::Nash::LcpStrategyResult"[T]: + stdlist[c_MixedStrategyProfile[T]] equilibria + bool success + cdef cppclass c_LcpBehaviorResult "Gambit::Nash::LcpBehaviorResult"[T]: + optional[c_MixedBehaviorProfile[T]] equilibrium + bool success + c_LcpStrategyResult[T] LcpStrategySolve[T]( c_Game, optional[size_t] p_stopAfter, int p_maxDepth, StrategyCallbackType[T] ) except +RuntimeError - stdlist[c_MixedBehaviorProfile[T]] LcpBehaviorSolve[T]( + c_LcpBehaviorResult[T] LcpBehaviorSolve[T]( c_Game, BehaviorCallbackType[T] ) except +RuntimeError cdef extern from "solvers/lp/lp.h": - stdlist[c_MixedStrategyProfile[T]] LpStrategySolve[T]( + cdef cppclass c_LpStrategyResult "Gambit::Nash::LpStrategyResult"[T]: + optional[c_MixedStrategyProfile[T]] equilibrium + bool success + cdef cppclass c_LpBehaviorResult "Gambit::Nash::LpBehaviorResult"[T]: + optional[c_MixedBehaviorProfile[T]] equilibrium + bool success + c_LpStrategyResult[T] LpStrategySolve[T]( c_Game, StrategyCallbackType[T] ) except +RuntimeError - stdlist[c_MixedBehaviorProfile[T]] LpBehaviorSolve[T]( + c_LpBehaviorResult[T] LpBehaviorSolve[T]( c_Game, BehaviorCallbackType[T] ) except +RuntimeError +cdef extern from "solvers/liap/liap.h" namespace "Gambit::Nash": + cdef enum class c_LiapTerminationReason "Gambit::Nash::LiapTerminationReason": + Converged + MinimizerFailed + RegretTargetNotReached + cdef extern from "solvers/liap/liap.h": - stdlist[c_MixedStrategyProfile[double]] LiapStrategySolve( + cdef cppclass c_LiapStrategyResult "Gambit::Nash::LiapStrategyResult": + optional[c_MixedStrategyProfile[double]] equilibrium + bool success + c_LiapTerminationReason reason + cdef cppclass c_LiapAgentResult "Gambit::Nash::LiapAgentResult": + optional[c_MixedBehaviorProfile[double]] equilibrium + bool success + c_LiapTerminationReason reason + c_LiapStrategyResult LiapStrategySolve( c_MixedStrategyProfile[double], double p_maxregret, int p_maxitsN, StrategyCallbackType[double], LiapEventCallbackType[c_MixedStrategyProfile[double]] ) except +RuntimeError - stdlist[c_MixedBehaviorProfile[double]] LiapAgentSolve( + c_LiapAgentResult LiapAgentSolve( 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( + cdef cppclass c_SimpdivStrategyResult "Gambit::Nash::SimpdivStrategyResult": + optional[c_MixedStrategyProfile[c_Rational]] equilibrium + bool success + c_SimpdivStrategyResult SimpdivStrategySolve( c_MixedStrategyProfile[c_Rational] start, c_Rational p_maxregret, int p_gridResize, int p_leashLength, StrategyCallbackType[c_Rational], SimpdivEventCallbackType ) except +RuntimeError +cdef extern from "solvers/gtracer/gtracer.h" namespace "Gambit::gametracer": + cdef enum class c_IPATerminationReason "Gambit::gametracer::IPATerminationReason": + Converged + MaxIterationsReached + NonfiniteStrategy + cdef extern from "solvers/ipa/ipa.h": - stdlist[c_MixedStrategyProfile[double]] IPAStrategySolve( + cdef cppclass c_IPAStrategyResult "Gambit::Nash::IPAStrategyResult": + optional[c_MixedStrategyProfile[double]] equilibrium + bool success + c_IPATerminationReason reason + c_IPAStrategyResult IPAStrategySolve( c_MixedStrategyProfile[double], StrategyCallbackType[double], IPAEventCallbackType ) except +RuntimeError +cdef extern from "solvers/gtracer/gtracer.h" namespace "Gambit::gametracer": + cdef enum class c_GNMTerminationReason "Gambit::gametracer::GNMTerminationReason": + NoMoreBoundaries + NoNextBoundary + NonfiniteStrategy + LambdaOutOfRange + ExcessiveError + cdef extern from "solvers/gnm/gnm.h": - stdlist[c_MixedStrategyProfile[double]] GNMStrategySolve( + cdef cppclass c_GNMStrategyResult "Gambit::Nash::GNMStrategyResult": + stdlist[c_MixedStrategyProfile[double]] equilibria + bool success + c_GNMTerminationReason reason + c_GNMStrategyResult GNMStrategySolve( c_MixedStrategyProfile[double], double p_endLambda, int p_steps, int p_localNewtonInterval, int p_localNewtonMaxits, StrategyCallbackType[double], GNMEventCallbackType ) except +RuntimeError +cdef extern from "solvers/hp/hp.h" namespace "Gambit::Nash": + cdef enum class c_HPTerminationReason "Gambit::Nash::HPTerminationReason": + Converged + TraceFailed + PolishFailed + RegretTargetNotReached + +cdef extern from "solvers/hp/hp.h": + cdef cppclass c_HPStrategyResult "Gambit::Nash::HPStrategyResult": + optional[c_MixedStrategyProfile[double]] equilibrium + bool success + c_HPTerminationReason reason + c_HPStrategyResult HPStrategySolve( + c_MixedStrategyProfile[double], double, StrategyCallbackType[double], + HPEventCallbackType + ) except +RuntimeError + cdef extern from "solvers/nashsupport/nashsupport.h": cdef cppclass c_PossibleNashStrategySupports "PossibleNashStrategySupports": c_PossibleNashStrategySupports(c_Game) except + optional[c_StrategySupportProfile] Next() except +RuntimeError cdef extern from "solvers/enumpoly/enumpoly.h": - stdlist[c_MixedStrategyProfile[double]] EnumPolyStrategySolve( + cdef cppclass c_EnumPolyStrategyResult "Gambit::Nash::EnumPolyStrategyResult": + stdlist[c_MixedStrategyProfile[double]] equilibria + bool success + cdef cppclass c_EnumPolyBehaviorResult "Gambit::Nash::EnumPolyBehaviorResult": + stdlist[c_MixedBehaviorProfile[double]] equilibria + bool success + c_EnumPolyStrategyResult EnumPolyStrategySolve( c_Game, optional[size_t], float, size_t, StrategyCallbackType[double], EnumPolyEventCallbackType[c_StrategySupportProfile] ) except +RuntimeError - stdlist[c_MixedBehaviorProfile[double]] EnumPolyBehaviorSolve( + c_EnumPolyBehaviorResult EnumPolyBehaviorSolve( c_Game, optional[size_t], float, size_t, BehaviorCallbackType[double], EnumPolyEventCallbackType[c_BehaviorSupportProfile] ) except +RuntimeError @@ -642,16 +725,35 @@ cdef extern from "solvers/logit/logit.h": int size() except + double getitem "operator[]"(int) except +IndexError + cdef enum class c_LogitTerminationReason "Gambit::LogitTerminationReason": + Converged + RegretTargetNotReached + + cdef cppclass c_LogitStrategyResult "Gambit::LogitStrategyResult": + optional[c_MixedStrategyProfile[double]] equilibrium + bool success + c_LogitTerminationReason reason + cdef cppclass c_LogitBehaviorResult "Gambit::LogitBehaviorResult": + optional[c_MixedBehaviorProfile[double]] equilibrium + bool success + c_LogitTerminationReason reason + + c_LogitStrategyResult LogitStrategySolveEquilibrium( + c_LogitQREMixedStrategyProfile, double, double, double, + LogitEventCallbackType[c_LogitQREMixedStrategyProfile] + ) except +RuntimeError + c_LogitBehaviorResult LogitBehaviorSolveEquilibrium( + c_LogitQREMixedBehaviorProfile, double, double, double, + LogitEventCallbackType[c_LogitQREMixedBehaviorProfile] + ) except +RuntimeError + cdef extern from "nash.h": pair[ - stdlist[c_MixedStrategyProfile[T]], stdlist[stdlist[c_MixedStrategyProfile[T]]] + c_EnumMixedStrategyResult[T], stdlist[stdlist[c_MixedStrategyProfile[T]]] ] EnumMixedStrategySolveCliquesWrapper[T]( c_Game, StrategyCallbackType[T] ) except +RuntimeError - stdlist[c_MixedBehaviorProfile[double]] LogitBehaviorSolveWrapper( - c_Game, double, double, double, LogitEventCallbackType[c_LogitQREMixedBehaviorProfile] - ) except + stdlist[c_LogitQREMixedBehaviorProfile] LogitBehaviorPrincipalBranchWrapper( c_Game, double, double, double ) except + @@ -663,9 +765,6 @@ cdef extern from "nash.h": shared_ptr[c_MixedBehaviorProfile[double]], bool, double, double, LogitEventCallbackType[c_LogitQREMixedBehaviorProfile] ) except + - stdlist[c_MixedStrategyProfile[double]] LogitStrategySolveWrapper( - c_Game, double, double, double, LogitEventCallbackType[c_LogitQREMixedStrategyProfile] - ) except + stdlist[c_LogitQREMixedStrategyProfile] LogitStrategyPrincipalBranchWrapper( c_Game, double, double, double ) except + @@ -677,6 +776,3 @@ cdef extern from "nash.h": shared_ptr[c_MixedStrategyProfile[double]], bool, double, double, LogitEventCallbackType[c_LogitQREMixedStrategyProfile] ) except + - stdlist[c_MixedStrategyProfile[double]] HPStrategySolveWrapper( - c_MixedStrategyProfile[double], double, HPEventCallbackType - ) except +RuntimeError diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index 815a27d786..bd6ad5c457 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -21,7 +21,6 @@ // #include "solvers/enummixed/enummixed.h" -#include "solvers/hp/hp.h" #include "solvers/logit/logit.h" #include "solvers/path/path.h" @@ -29,7 +28,7 @@ using namespace std; using namespace Gambit; template -std::pair>, std::list>>> +std::pair, std::list>>> EnumMixedStrategySolveCliquesWrapper( const Game &p_game, Nash::StrategyCallbackType p_onEquilibrium = Nash::NullStrategyCallback) @@ -39,22 +38,8 @@ EnumMixedStrategySolveCliquesWrapper( for (auto &clique : solution->GetCliques()) { cliques.emplace_back(clique.begin(), clique.end()); } - return {solution->GetExtremeEquilibria(), cliques}; -} - -std::list> -LogitBehaviorSolveWrapper(const Game &p_game, double p_regret, double p_firstStep, - double p_maxAccel, - LogitEventCallbackType p_onEvent = - NullLogitEventCallback) -{ - std::list> ret; - ret.push_back(LogitBehaviorSolve(LogitQREMixedBehaviorProfile(p_game), p_regret, - PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel, - Nash::NullBehaviorCallback, p_onEvent) - .back() - .GetProfile()); - return ret; + return {Nash::EnumMixedStrategyResult{solution->GetExtremeEquilibria(), solution->success}, + cliques}; } inline std::list @@ -92,21 +77,6 @@ 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, - LogitEventCallbackType p_onEvent = - NullLogitEventCallback) -{ - std::list> ret; - ret.push_back(LogitStrategySolve(LogitQREMixedStrategyProfile(p_game), p_regret, - PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel, - Nash::NullStrategyCallback, p_onEvent) - .back() - .GetProfile()); - return ret; -} - inline std::list LogitStrategyPrincipalBranchWrapper(const Game &p_game, double p_regret, double p_firstStep, double p_maxAccel) @@ -141,11 +111,3 @@ LogitStrategyEstimateWrapper(std::shared_ptr> p_fre LogitStrategyEstimate(*p_frequencies, 1000000.0, PathTracer::TraceDirection::Positive, p_stopAtLocal, p_firstStep, p_maxAccel, p_onEvent)); } - -std::list> -HPStrategySolveWrapper(const MixedStrategyProfile &p_prior, double p_maxRegret, - Nash::HPEventCallbackType p_onEvent = Nash::NullHPEventCallback) -{ - return Nash::HPStrategySolve(p_prior, p_maxRegret, Nash::NullStrategyCallback, - p_onEvent); -} diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index 595a422235..dda9a67d3f 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -22,6 +22,7 @@ import cython import dataclasses import enum +import pathlib from libcpp.memory cimport shared_ptr, make_shared from cython.operator cimport dereference as deref from libcpp.list cimport list as stdlist @@ -130,6 +131,33 @@ class IPATerminationEvent: message: str +class HPTerminationReason(enum.Enum): + """Why a call to :ref:`the homotopy method of Herings and Peeters ` did not + return an accepted equilibrium. + """ + CONVERGED = 0 + TRACE_FAILED = 1 + POLISH_FAILED = 2 + REGRET_TARGET_NOT_REACHED = 3 + + +class LiapTerminationReason(enum.Enum): + """Why a call to :ref:`Lyapunov function minimization ` did not return an + accepted equilibrium. + """ + CONVERGED = 0 + MINIMIZER_FAILED = 1 + REGRET_TARGET_NOT_REACHED = 2 + + +class LogitTerminationReason(enum.Enum): + """Why tracing the :ref:`logit QRE correspondence ` to an equilibrium did + not return an accepted equilibrium. + """ + CONVERGED = 0 + REGRET_TARGET_NOT_REACHED = 1 + + @dataclasses.dataclass(frozen=True) class EnumPolyCandidateSupportEvent: """Reports a support profile examined by :ref:`enumpoly ` as a candidate @@ -154,6 +182,325 @@ class EnumPolyBudgetExceededSupportEvent: support: StrategySupportProfile | BehaviorSupportProfile +MixedStrategyEquilibriumSet = list[MixedStrategyProfile] +MixedBehaviorEquilibriumSet = list[MixedBehaviorProfile] + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class NashResultBase: + """Common attributes shared by every result of a method which computes Nash + equilibria in a game. Each solution method returns its own dataclass, derived + from this one, whose additional attributes reflect that method's own parameters + and the way it can succeed or fail. + + Attributes + ---------- + game : Game + The game on which the method was run. + rational : bool + Whether the calculation used exact rational arithmetic (True) or floating-point + (False). + use_strategic : bool + Whether the method solved using the strategic representation (True) or the + extensive representation (False). + """ + game: Game = dataclasses.field(repr=False) + rational: bool + use_strategic: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EnumPureResult(NashResultBase): + """The result of enumerating pure-strategy Nash equilibria (`enumpure_solve`) or + pure-strategy agent Nash equilibria (`enumpure_agent_solve`). + + Attributes + ---------- + equilibria : MixedStrategyEquilibriumSet or MixedBehaviorEquilibriumSet + The pure-strategy equilibria found, represented as degenerate mixed profiles. + success : bool + Always True; enumeration always completes (an empty `equilibria` means the + game genuinely has no pure-strategy equilibrium of this kind, not a failure). + """ + equilibria: MixedStrategyEquilibriumSet | MixedBehaviorEquilibriumSet + success: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EnumMixedResult(NashResultBase): + """The result of `enummixed_solve`. + + Attributes + ---------- + equilibria : MixedStrategyEquilibriumSet + The extreme equilibria found. + success : bool + Always True; enumeration always completes. + lrsnash_path : pathlib.Path or str, optional + Set if `lrsnash` was used to solve the systems of equations. + cliques : list of MixedStrategyEquilibriumSet, optional + Set if `cliques=True` was requested: the sets of extreme equilibria which + are connected to one another. + """ + equilibria: MixedStrategyEquilibriumSet + success: bool + lrsnash_path: pathlib.Path | str | None = None + cliques: list[MixedStrategyEquilibriumSet] | None = None + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class LcpStrategyResult(NashResultBase): + """The result of `lcp_solve` when solving on the strategic representation. + + Attributes + ---------- + stop_after : int, optional + The maximum number of equilibria which were to be computed, if specified. + max_depth : int, optional + The maximum depth of recursion in the search, if specified. + equilibria : MixedStrategyEquilibriumSet + The equilibria found. + success : bool + False if `max_depth` caused the search to be pruned before it could + establish it had found all accessible equilibria; True otherwise. + """ + stop_after: int | None + max_depth: int | None + equilibria: MixedStrategyEquilibriumSet + success: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class LcpBehaviorResult(NashResultBase): + """The result of `lcp_solve` when solving on the extensive representation. + + Attributes + ---------- + equilibrium : MixedBehaviorProfile, optional + The equilibrium found. Lemke's algorithm on the extensive form always + finds exactly one equilibrium, so this is never None. + success : bool + Always True. + """ + equilibrium: MixedBehaviorProfile | None + success: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class LpResult(NashResultBase): + """The result of `lp_solve`. + + Attributes + ---------- + equilibrium : MixedStrategyProfile or MixedBehaviorProfile, optional + The equilibrium found. Linear programming always finds exactly one + equilibrium, so this is never None. + success : bool + Always True. + """ + equilibrium: MixedStrategyProfile | MixedBehaviorProfile | None + success: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class LiapResult(NashResultBase): + """The result of `liap_solve` or `liap_agent_solve`. + + Attributes + ---------- + start : MixedStrategyProfileDouble or MixedBehaviorProfileDouble + The starting profile function minimization was run from. + maxregret : float + The acceptance criterion for approximate Nash equilibrium which was used. + maxiter : int + The maximum number of iterations in function minimization which was allowed. + equilibrium : MixedStrategyProfileDouble or MixedBehaviorProfileDouble, optional + The equilibrium found, or None if minimization did not reach an accepted + equilibrium. + success : bool + Whether `equilibrium` was accepted. + reason : LiapTerminationReason + Why minimization did not reach an accepted equilibrium, if it did not. + """ + start: MixedStrategyProfileDouble | MixedBehaviorProfileDouble + maxregret: float + maxiter: int + equilibrium: MixedStrategyProfileDouble | MixedBehaviorProfileDouble | None + success: bool + reason: LiapTerminationReason + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class SimpdivResult(NashResultBase): + """The result of `simpdiv_solve`. + + Attributes + ---------- + start : MixedStrategyProfileRational + The starting profile the algorithm was run from. + maxregret : Rational + The acceptance criterion for approximate Nash equilibrium which was used. + refine : int + The rate at which the triangulation was refined at each iteration. + leash : int, optional + The maximum number of grid steps the method was allowed to explore, if + specified. + equilibrium : MixedStrategyProfileRational, optional + The equilibrium found. Simplicial subdivision is guaranteed to converge, + so this is never None unless `leash` caused the search to be cut short. + success : bool + False only if `leash` was reached before converging; True otherwise. + """ + start: MixedStrategyProfileRational + maxregret: Rational + refine: int + leash: int | None + equilibrium: MixedStrategyProfileRational | None + success: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class IPAResult(NashResultBase): + """The result of `ipa_solve`. + + Attributes + ---------- + perturbation : MixedStrategyProfileDouble + The perturbation vector the iteration was run from. + equilibrium : MixedStrategyProfileDouble, optional + The equilibrium found, or None if iteration did not reach an accepted + equilibrium. + success : bool + Whether `equilibrium` was accepted. + reason : IPATerminationReason + Why iteration did not reach an accepted equilibrium, if it did not. + """ + perturbation: MixedStrategyProfileDouble + equilibrium: MixedStrategyProfileDouble | None + success: bool + reason: IPATerminationReason + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class GNMResult(NashResultBase): + """The result of `gnm_solve`. + + Attributes + ---------- + perturbation : MixedStrategyProfileDouble + The perturbation vector the path-following run was traced from. + end_lambda : float + The value of the perturbation magnitude lambda at which tracing was set + to terminate. + steps : int + The number of steps taken within a support cell which was used. + local_newton_interval : int + The frequency of local Newton method correction steps which was used. + local_newton_maxits : int + The maximum number of iterations in a local Newton method step which + was allowed. + equilibria : MixedStrategyEquilibriumSet + The equilibria found along the path. + success : bool + False if the run ended in a numerical breakdown (see `reason`); True if + it ended normally (whether or not further equilibria might exist beyond + where tracing stopped). + reason : GNMTerminationReason + Why path-following terminated. + """ + perturbation: MixedStrategyProfileDouble + end_lambda: float + steps: int + local_newton_interval: int + local_newton_maxits: int + equilibria: MixedStrategyEquilibriumSet + success: bool + reason: GNMTerminationReason + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EnumPolyResult(NashResultBase): + """The result of `enumpoly_solve`. + + Attributes + ---------- + stop_after : int, optional + The maximum number of equilibria which were to be computed, if specified. + maxregret : float + The acceptance criterion for approximate Nash equilibrium which was used. + max_rectangles : int, optional + The maximum number of rectangles examined per support which was allowed. + Not set when `phcpack_path` was used. + phcpack_path : pathlib.Path or str, optional + Set if PHCpack was used to solve the systems of equations. + equilibria : MixedStrategyEquilibriumSet or MixedBehaviorEquilibriumSet + The equilibria found. + success : bool + Always True. (A support being skipped as singular, or exhausting its + rectangle budget, is reported only via `event_callback`; it is not yet + reflected here.) + """ + stop_after: int | None + maxregret: float + max_rectangles: int | None + phcpack_path: pathlib.Path | str | None = None + equilibria: MixedStrategyEquilibriumSet | MixedBehaviorEquilibriumSet + success: bool + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class LogitResult(NashResultBase): + """The result of `logit_solve`. + + Attributes + ---------- + maxregret : float + The acceptance criterion for approximate Nash equilibrium which was used. + first_step : float + The arclength of the initial step which was used. + max_accel : float + The maximum rate at which the arclength step size was allowed to lengthen. + equilibrium : MixedStrategyProfileDouble or MixedBehaviorProfileDouble, optional + The equilibrium found, or None if tracing did not reach an accepted + equilibrium. + success : bool + Whether `equilibrium` was accepted. + reason : LogitTerminationReason + Why tracing did not reach an accepted equilibrium, if it did not. + """ + maxregret: float + first_step: float + max_accel: float + equilibrium: MixedStrategyProfileDouble | MixedBehaviorProfileDouble | None + success: bool + reason: LogitTerminationReason + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class HPResult(NashResultBase): + """The result of `hp_solve`. + + Attributes + ---------- + prior : MixedStrategyProfileDouble + The prior distribution the homotopy path was traced from. + maxregret : float + The acceptance criterion for approximate Nash equilibrium which was used. + equilibrium : MixedStrategyProfileDouble, optional + The equilibrium found, or None if tracing did not reach an accepted + equilibrium. + success : bool + Whether `equilibrium` was accepted. + reason : HPTerminationReason + Why tracing did not reach an accepted equilibrium, if it did not. + """ + prior: MixedStrategyProfileDouble + maxregret: float + equilibrium: MixedStrategyProfileDouble | None + success: bool + reason: HPTerminationReason + + cdef public string InvokeStrategyCallbackDouble( callback, profile: shared_ptr[c_MixedStrategyProfile[float]] ): @@ -452,186 +799,281 @@ def _convert_mbpr( for profile in make_list_of_pointer(inlist)] -def _enumpure_strategy_solve( - game: Game, nash_callback: object = None -) -> list[MixedStrategyProfile[c_Rational]]: - return _convert_mspr( - EnumPureStrategySolve(game.game, MakeStrategyCallback[c_Rational](nash_callback)) +@cython.cfunc +def _convert_msp_opt_d( + opt: optional[c_MixedStrategyProfile[float]] +) -> MixedStrategyProfile[double] | None: + if not opt.has_value(): + return None + return MixedStrategyProfileDouble.wrap( + make_shared[c_MixedStrategyProfile[double]](opt.value()) ) -def _enumpure_agent_solve( - game: Game, nash_callback: object = None -) -> list[MixedBehaviorProfileRational]: - return _convert_mbpr( - EnumPureAgentSolve(game.game, MakeBehaviorCallback[c_Rational](nash_callback)) +@cython.cfunc +def _convert_msp_opt_r( + opt: optional[c_MixedStrategyProfile[c_Rational]] +) -> MixedStrategyProfile[c_Rational] | None: + if not opt.has_value(): + return None + return MixedStrategyProfileRational.wrap( + make_shared[c_MixedStrategyProfile[c_Rational]](opt.value()) ) -def _enummixed_strategy_solve_double( - game: Game, nash_callback: object = None -) -> list[MixedStrategyProfileDouble]: - return _convert_mspd( - EnumMixedStrategySolve[double](game.game, MakeStrategyCallback[double](nash_callback)) +@cython.cfunc +def _convert_mbp_opt_d( + opt: optional[c_MixedBehaviorProfile[float]] +) -> MixedBehaviorProfile[double] | None: + if not opt.has_value(): + return None + return MixedBehaviorProfileDouble.wrap( + make_shared[c_MixedBehaviorProfile[double]](opt.value()) + ) + + +@cython.cfunc +def _convert_mbp_opt_r( + opt: optional[c_MixedBehaviorProfile[c_Rational]] +) -> MixedBehaviorProfile[c_Rational] | None: + if not opt.has_value(): + return None + return MixedBehaviorProfileRational.wrap( + make_shared[c_MixedBehaviorProfile[c_Rational]](opt.value()) + ) + + +def _enumpure_strategy_solve(game: Game, nash_callback: object = None) -> EnumPureResult: + result: c_EnumPureStrategyResult = EnumPureStrategySolve( + game.game, MakeStrategyCallback[c_Rational](nash_callback) + ) + return EnumPureResult( + game=game, rational=True, use_strategic=True, + equilibria=_convert_mspr(result.equilibria), success=result.success, + ) + + +def _enumpure_agent_solve(game: Game, nash_callback: object = None) -> EnumPureResult: + result: c_EnumPureAgentResult = EnumPureAgentSolve( + game.game, MakeBehaviorCallback[c_Rational](nash_callback) + ) + return EnumPureResult( + game=game, rational=True, use_strategic=False, + equilibria=_convert_mbpr(result.equilibria), success=result.success, + ) + + +def _enummixed_strategy_solve_double(game: Game, nash_callback: object = None) -> EnumMixedResult: + result: c_EnumMixedStrategyResult[float] = EnumMixedStrategySolve[double]( + game.game, MakeStrategyCallback[double](nash_callback) + ) + return EnumMixedResult( + game=game, rational=False, use_strategic=True, + equilibria=_convert_mspd(result.equilibria), success=result.success, ) def _enummixed_strategy_solve_rational( game: Game, nash_callback: object = None -) -> list[MixedStrategyProfileRational]: - return _convert_mspr( - EnumMixedStrategySolve[c_Rational]( - game.game, MakeStrategyCallback[c_Rational](nash_callback) - ) +) -> EnumMixedResult: + result: c_EnumMixedStrategyResult[c_Rational] = EnumMixedStrategySolve[c_Rational]( + game.game, MakeStrategyCallback[c_Rational](nash_callback) + ) + return EnumMixedResult( + game=game, rational=True, use_strategic=True, + equilibria=_convert_mspr(result.equilibria), success=result.success, ) def _enummixed_strategy_solve_cliques_double( game: Game, nash_callback: object = None -) -> tuple[list[MixedStrategyProfileDouble], list[list[MixedStrategyProfileDouble]]]: +) -> EnumMixedResult: result: pair[ - stdlist[c_MixedStrategyProfile[float]], stdlist[stdlist[c_MixedStrategyProfile[float]]] + c_EnumMixedStrategyResult[float], stdlist[stdlist[c_MixedStrategyProfile[float]]] ] = EnumMixedStrategySolveCliquesWrapper[double]( game.game, MakeStrategyCallback[double](nash_callback) ) - return ( - _convert_mspd(result.first), - [_convert_mspd(clique) for clique in result.second], + return EnumMixedResult( + game=game, rational=False, use_strategic=True, + equilibria=_convert_mspd(result.first.equilibria), + success=result.first.success, + cliques=[_convert_mspd(clique) for clique in result.second], ) def _enummixed_strategy_solve_cliques_rational( game: Game, nash_callback: object = None -) -> tuple[list[MixedStrategyProfileRational], list[list[MixedStrategyProfileRational]]]: +) -> EnumMixedResult: result: pair[ - stdlist[c_MixedStrategyProfile[c_Rational]], + c_EnumMixedStrategyResult[c_Rational], stdlist[stdlist[c_MixedStrategyProfile[c_Rational]]] ] = EnumMixedStrategySolveCliquesWrapper[c_Rational]( game.game, MakeStrategyCallback[c_Rational](nash_callback) ) - return ( - _convert_mspr(result.first), - [_convert_mspr(clique) for clique in result.second], + return EnumMixedResult( + game=game, rational=True, use_strategic=True, + equilibria=_convert_mspr(result.first.equilibria), + success=result.first.success, + cliques=[_convert_mspr(clique) for clique in result.second], ) -def _lcp_behavior_solve_double( - game: Game, nash_callback: object = None -) -> list[MixedBehaviorProfileDouble]: - return _convert_mbpd( - LcpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](nash_callback)) +def _lcp_behavior_solve_double(game: Game, nash_callback: object = None) -> LcpBehaviorResult: + result: c_LcpBehaviorResult[float] = LcpBehaviorSolve[double]( + game.game, MakeBehaviorCallback[double](nash_callback) + ) + return LcpBehaviorResult( + game=game, rational=False, use_strategic=False, + equilibrium=_convert_mbp_opt_d(result.equilibrium), success=result.success, ) -def _lcp_behavior_solve_rational( - game: Game, nash_callback: object = None -) -> list[MixedBehaviorProfileRational]: - return _convert_mbpr( - LcpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](nash_callback)) +def _lcp_behavior_solve_rational(game: Game, nash_callback: object = None) -> LcpBehaviorResult: + result: c_LcpBehaviorResult[c_Rational] = LcpBehaviorSolve[c_Rational]( + game.game, MakeBehaviorCallback[c_Rational](nash_callback) + ) + return LcpBehaviorResult( + game=game, rational=True, use_strategic=False, + equilibrium=_convert_mbp_opt_r(result.equilibrium), success=result.success, ) def _lcp_strategy_solve_double( game: Game, stop_after, max_depth: int, nash_callback: object = None -) -> list[MixedStrategyProfileDouble]: +) -> LcpStrategyResult: 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](nash_callback) - ) + result: c_LcpStrategyResult[float] = LcpStrategySolve[double]( + game.game, c_stop_after, max_depth, MakeStrategyCallback[double](nash_callback) + ) + return LcpStrategyResult( + game=game, rational=False, use_strategic=True, + stop_after=stop_after, max_depth=max_depth or None, + equilibria=_convert_mspd(result.equilibria), success=result.success, ) def _lcp_strategy_solve_rational( game: Game, stop_after, max_depth: int, nash_callback: object = None -) -> list[MixedStrategyProfileRational]: +) -> LcpStrategyResult: 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](nash_callback) - ) + result: c_LcpStrategyResult[c_Rational] = LcpStrategySolve[c_Rational]( + game.game, c_stop_after, max_depth, MakeStrategyCallback[c_Rational](nash_callback) + ) + return LcpStrategyResult( + game=game, rational=True, use_strategic=True, + stop_after=stop_after, max_depth=max_depth or None, + equilibria=_convert_mspr(result.equilibria), success=result.success, ) -def _lp_behavior_solve_double( - game: Game, nash_callback: object = None -) -> list[MixedBehaviorProfileDouble]: - return _convert_mbpd( - LpBehaviorSolve[double](game.game, MakeBehaviorCallback[double](nash_callback)) +def _lp_behavior_solve_double(game: Game, nash_callback: object = None) -> LpResult: + result: c_LpBehaviorResult[float] = LpBehaviorSolve[double]( + game.game, MakeBehaviorCallback[double](nash_callback) + ) + return LpResult( + game=game, rational=False, use_strategic=False, + equilibrium=_convert_mbp_opt_d(result.equilibrium), success=result.success, ) -def _lp_behavior_solve_rational( - game: Game, nash_callback: object = None -) -> list[MixedBehaviorProfileRational]: - return _convert_mbpr( - LpBehaviorSolve[c_Rational](game.game, MakeBehaviorCallback[c_Rational](nash_callback)) +def _lp_behavior_solve_rational(game: Game, nash_callback: object = None) -> LpResult: + result: c_LpBehaviorResult[c_Rational] = LpBehaviorSolve[c_Rational]( + game.game, MakeBehaviorCallback[c_Rational](nash_callback) + ) + return LpResult( + game=game, rational=True, use_strategic=False, + equilibrium=_convert_mbp_opt_r(result.equilibrium), success=result.success, ) -def _lp_strategy_solve_double( - game: Game, nash_callback: object = None -) -> list[MixedStrategyProfileDouble]: - return _convert_mspd( - LpStrategySolve[double](game.game, MakeStrategyCallback[double](nash_callback)) +def _lp_strategy_solve_double(game: Game, nash_callback: object = None) -> LpResult: + result: c_LpStrategyResult[float] = LpStrategySolve[double]( + game.game, MakeStrategyCallback[double](nash_callback) + ) + return LpResult( + game=game, rational=False, use_strategic=True, + equilibrium=_convert_msp_opt_d(result.equilibrium), success=result.success, ) -def _lp_strategy_solve_rational( - game: Game, nash_callback: object = None -) -> list[MixedStrategyProfileRational]: - return _convert_mspr( - LpStrategySolve[c_Rational](game.game, MakeStrategyCallback[c_Rational](nash_callback)) +def _lp_strategy_solve_rational(game: Game, nash_callback: object = None) -> LpResult: + result: c_LpStrategyResult[c_Rational] = LpStrategySolve[c_Rational]( + game.game, MakeStrategyCallback[c_Rational](nash_callback) + ) + return LpResult( + game=game, rational=True, use_strategic=True, + equilibrium=_convert_msp_opt_r(result.equilibrium), success=result.success, ) -def _liap_strategy_solve(start: MixedStrategyProfileDouble, - maxregret: float, - maxiter: int, - nash_callback: object = None, - event_callback: object = None) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(LiapStrategySolve( +def _liap_strategy_solve( + start: MixedStrategyProfileDouble, maxregret: float, maxiter: int, + nash_callback: object = None, event_callback: object = None +) -> LiapResult: + result: c_LiapStrategyResult = LiapStrategySolve( deref(start.profile), maxregret, maxiter, MakeStrategyCallback[double](nash_callback), MakeLiapEventCallback[c_MixedStrategyProfile[double]](event_callback) - )) + ) + return LiapResult( + game=start.game, rational=False, use_strategic=True, + start=start, maxregret=maxregret, maxiter=maxiter, + equilibrium=_convert_msp_opt_d(result.equilibrium), success=result.success, + reason=LiapTerminationReason(result.reason), + ) -def _liap_behavior_solve(start: MixedBehaviorProfileDouble, - maxregret: float, - maxiter: int, - nash_callback: object = None, - event_callback: object = None) -> list[MixedBehaviorProfileDouble]: - return _convert_mbpd(LiapAgentSolve( +def _liap_behavior_solve( + start: MixedBehaviorProfileDouble, maxregret: float, maxiter: int, + nash_callback: object = None, event_callback: object = None +) -> LiapResult: + result: c_LiapAgentResult = LiapAgentSolve( deref(start.profile), maxregret, maxiter, MakeBehaviorCallback[double](nash_callback), MakeLiapEventCallback[c_MixedBehaviorProfile[double]](event_callback) - )) + ) + return LiapResult( + game=start.game, rational=False, use_strategic=False, + start=start, maxregret=maxregret, maxiter=maxiter, + equilibrium=_convert_mbp_opt_d(result.equilibrium), success=result.success, + reason=LiapTerminationReason(result.reason), + ) def _simpdiv_strategy_solve( start: MixedStrategyProfileRational, maxregret: Rational, gridstep: int, leash: int, nash_callback: object = None, event_callback: object = None -) -> list[MixedStrategyProfileRational]: - return _convert_mspr(SimpdivStrategySolve( +) -> SimpdivResult: + result: c_SimpdivStrategyResult = SimpdivStrategySolve( deref(start.profile), to_rational(str(maxregret).encode("ascii")), gridstep, leash, MakeStrategyCallback[c_Rational](nash_callback), MakeSimpdivEventCallback(event_callback) - )) + ) + return SimpdivResult( + game=start.game, rational=True, use_strategic=True, + start=start, maxregret=maxregret, refine=gridstep, leash=leash or None, + equilibrium=_convert_msp_opt_r(result.equilibrium), success=result.success, + ) def _ipa_strategy_solve( pert: MixedStrategyProfileDouble, nash_callback: object = None, event_callback: object = None -) -> list[MixedStrategyProfileDouble]: +) -> IPAResult: try: - return _convert_mspd(IPAStrategySolve( + result: c_IPAStrategyResult = IPAStrategySolve( deref(pert.profile), MakeStrategyCallback[double](nash_callback), MakeIPAEventCallback(event_callback) - )) + ) + return IPAResult( + game=pert.game, rational=False, use_strategic=True, + perturbation=pert, + equilibrium=_convert_msp_opt_d(result.equilibrium), success=result.success, + reason=IPATerminationReason(result.reason), + ) except RuntimeError as e: if "does not have unique maximizer" in str(e): raise ValueError(str(e)) from None @@ -646,12 +1088,20 @@ def _gnm_strategy_solve( local_newton_maxits: int, nash_callback: object = None, event_callback: object = None, -) -> list[MixedStrategyProfileDouble]: +) -> GNMResult: try: - return _convert_mspd(GNMStrategySolve( + result: c_GNMStrategyResult = GNMStrategySolve( deref(pert.profile), end_lambda, steps, local_newton_interval, local_newton_maxits, MakeStrategyCallback[double](nash_callback), MakeGNMEventCallback(event_callback) - )) + ) + return GNMResult( + game=pert.game, rational=False, use_strategic=True, + perturbation=pert, end_lambda=end_lambda, steps=steps, + local_newton_interval=local_newton_interval, + local_newton_maxits=local_newton_maxits, + equilibria=_convert_mspd(result.equilibria), success=result.success, + reason=GNMTerminationReason(result.reason), + ) except RuntimeError as e: if "does not have unique maximizer" in str(e): raise ValueError(str(e)) from None @@ -681,15 +1131,20 @@ def _enumpoly_strategy_solve( max_rectangles: int, nash_callback: object = None, event_callback: object = None, -) -> list[MixedStrategyProfileDouble]: +) -> EnumPolyResult: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after - return _convert_mspd(EnumPolyStrategySolve( + result: c_EnumPolyStrategyResult = EnumPolyStrategySolve( game.game, c_stop_after, maxregret, max_rectangles, MakeStrategyCallback[double](nash_callback), MakeEnumPolyEventCallback[c_StrategySupportProfile](event_callback) - )) + ) + return EnumPolyResult( + game=game, rational=False, use_strategic=True, + stop_after=stop_after, maxregret=maxregret, max_rectangles=max_rectangles, + equilibria=_convert_mspd(result.equilibria), success=result.success, + ) def _enumpoly_behavior_solve( @@ -699,35 +1154,54 @@ def _enumpoly_behavior_solve( max_rectangles: int, nash_callback: object = None, event_callback: object = None, -) -> list[MixedBehaviorProfileDouble]: +) -> EnumPolyResult: cdef optional[size_t] c_stop_after if stop_after is not None: c_stop_after = stop_after - return _convert_mbpd(EnumPolyBehaviorSolve( + result: c_EnumPolyBehaviorResult = EnumPolyBehaviorSolve( game.game, c_stop_after, maxregret, max_rectangles, MakeBehaviorCallback[double](nash_callback), MakeEnumPolyEventCallback[c_BehaviorSupportProfile](event_callback) - )) + ) + return EnumPolyResult( + game=game, rational=False, use_strategic=False, + stop_after=stop_after, maxregret=maxregret, max_rectangles=max_rectangles, + equilibria=_convert_mbpd(result.equilibria), success=result.success, + ) 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, +) -> LogitResult: + result: c_LogitStrategyResult = LogitStrategySolveEquilibrium( + deref(make_shared[c_LogitQREMixedStrategyProfile](game.game)), + maxregret, first_step, max_accel, MakeLogitEventCallback[c_LogitQREMixedStrategyProfile](event_callback) - )) + ) + return LogitResult( + game=game, rational=False, use_strategic=True, + maxregret=maxregret, first_step=first_step, max_accel=max_accel, + equilibrium=_convert_msp_opt_d(result.equilibrium), success=result.success, + reason=LogitTerminationReason(result.reason), + ) 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, +) -> LogitResult: + result: c_LogitBehaviorResult = LogitBehaviorSolveEquilibrium( + deref(make_shared[c_LogitQREMixedBehaviorProfile](game.game)), + maxregret, first_step, max_accel, MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) - )) + ) + return LogitResult( + game=game, rational=False, use_strategic=False, + maxregret=maxregret, first_step=first_step, max_accel=max_accel, + equilibrium=_convert_mbp_opt_d(result.equilibrium), success=result.success, + reason=LogitTerminationReason(result.reason), + ) @cython.cclass @@ -916,7 +1390,14 @@ def _hp_strategy_solve( prior: MixedStrategyProfileDouble, maxregret: float, event_callback: object = None, -) -> list[MixedStrategyProfileDouble]: - return _convert_mspd(HPStrategySolveWrapper( - deref(prior.profile), maxregret, MakeHPEventCallback(event_callback) - )) +) -> HPResult: + result: c_HPStrategyResult = HPStrategySolve( + deref(prior.profile), maxregret, MakeStrategyCallback[double](None), + MakeHPEventCallback(event_callback) + ) + return HPResult( + game=prior.game, rational=False, use_strategic=True, + prior=prior, maxregret=maxregret, + equilibrium=_convert_msp_opt_d(result.equilibrium), success=result.success, + reason=HPTerminationReason(result.reason), + ) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 8023776135..8a4fa58644 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -22,51 +22,41 @@ # """ A set of utilities for computing Nash equilibria + +The result classes returned by the functions in this module (``EnumPureResult``, +``HPResult``, and so on) are defined in the compiled `pygambit.gambit` extension, +alongside the C++ solvers that populate them, and re-exported here for convenient, +documented access as ``pygambit.nash.XResult``. This module itself only validates +and normalizes arguments before delegating to the extension. """ from __future__ import annotations -import dataclasses import pathlib from collections.abc import Callable, Iterator from numbers import Integral import pygambit.gambit as libgbt +from pygambit.gambit import ( # noqa: F401 + EnumMixedResult, + EnumPolyResult, + EnumPureResult, + GNMResult, + HPResult, + IPAResult, + LcpBehaviorResult, + LcpStrategyResult, + LiapResult, + LogitResult, + LpResult, + MixedBehaviorEquilibriumSet, + MixedStrategyEquilibriumSet, + NashResultBase, + SimpdivResult, +) from . import nashlrs, nashphc -MixedStrategyEquilibriumSet = list[libgbt.MixedStrategyProfile] -MixedBehaviorEquilibriumSet = list[libgbt.MixedBehaviorProfile] - - -@dataclasses.dataclass(frozen=True) -class NashComputationResult: - """Represents the result of a method which computes Nash equilibria in a game. - - Attributes - ---------- - game : Game - The game on which the method was run. - method : str - A string indicating the name of the method used. - rational : bool - Whether the calculation used exact rational arithmetic (True) or floating-point - (False). - use_strategic : bool - Whether the method solved using the strategic representation (True) or the - extensive representation (False). - equilibria : MixedStrategyEquilibriumSet or MixedBehaviorEquilibriumSet - The list of equilibrium profiles computed. - parameters : dict - A dictionary recording any additional algorithm parameters used. - """ - game: libgbt.Game = dataclasses.field(repr=False) - method: str - rational: bool - use_strategic: bool - equilibria: MixedStrategyEquilibriumSet | MixedBehaviorEquilibriumSet - parameters: dict = dataclasses.field(default_factory=dict) - def _validate_stop_after(funcname: str, stop_after: int | None) -> None: """Shared validation for a `stop_after` keyword argument, accepted by `lcp_solve`/ @@ -113,7 +103,7 @@ def _normalize_perturbation( def enumpure_solve( game: libgbt.Game, nash_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, -) -> NashComputationResult: +) -> EnumPureResult: """Compute all :ref:`pure-strategy Nash equilibria ` of game. .. versionchanged:: 16.5.0 @@ -136,26 +126,20 @@ def enumpure_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : EnumPureResult + The result represented as an ``EnumPureResult`` object. See Also -------- enumpure_agent_solve """ - return NashComputationResult( - game=game, - method="enumpure", - rational=True, - use_strategic=True, - equilibria=libgbt._enumpure_strategy_solve(game, nash_callback) - ) + return libgbt._enumpure_strategy_solve(game, nash_callback) def enumpure_agent_solve( game: libgbt.Game, nash_callback: Callable[[libgbt.MixedBehaviorProfileRational], None] | None = None, -) -> NashComputationResult: +) -> EnumPureResult: """Compute all :ref:`pure-strategy agent Nash equilibria ` of game. .. versionadded:: 16.5.0 @@ -177,20 +161,14 @@ def enumpure_agent_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : EnumPureResult + The result represented as an ``EnumPureResult`` object. See Also -------- enumpure_solve """ - return NashComputationResult( - game=game, - method="enumpure-agent", - rational=True, - use_strategic=False, - equilibria=libgbt._enumpure_agent_solve(game, nash_callback) - ) + return libgbt._enumpure_agent_solve(game, nash_callback) def enummixed_solve( @@ -201,7 +179,7 @@ def enummixed_solve( [libgbt.MixedStrategyProfile], None ] | None = None, cliques: bool = False, -) -> NashComputationResult: +) -> EnumMixedResult: """Compute all :ref:`mixed-strategy Nash equilibria ` of a two-player game using the strategic representation. @@ -229,15 +207,15 @@ def enummixed_solve( cliques : bool, default False If specified and True, also compute the sets of extreme equilibria which - are connected to one another, returned as a list of lists of equilibria in - `parameters["cliques"]`. Not available when `lrsnash_path` is specified. + are connected to one another, returned as `res.cliques`. Not available + when `lrsnash_path` is specified. .. versionadded:: 17.0.0 Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : EnumMixedResult + The result represented as an ``EnumMixedResult`` object. Raises ------ @@ -262,42 +240,21 @@ def enummixed_solve( "enummixed_solve(): cliques cannot be used with lrsnash_path" ) equilibria = nashlrs.lrsnash_solve(game, lrsnash_path=lrsnash_path) - return NashComputationResult( + return EnumMixedResult( game=game, - method="enummixed", rational=True, use_strategic=True, - parameters={"lrsnash_path": lrsnash_path}, + lrsnash_path=lrsnash_path, equilibria=equilibria, + success=True, ) if cliques: if rational: - equilibria, clique_list = libgbt._enummixed_strategy_solve_cliques_rational( - game, nash_callback - ) - else: - equilibria, clique_list = libgbt._enummixed_strategy_solve_cliques_double( - game, nash_callback - ) - return NashComputationResult( - game=game, - method="enummixed", - rational=rational, - use_strategic=True, - equilibria=equilibria, - parameters={"cliques": clique_list}, - ) + return libgbt._enummixed_strategy_solve_cliques_rational(game, nash_callback) + return libgbt._enummixed_strategy_solve_cliques_double(game, nash_callback) if rational: - equilibria = libgbt._enummixed_strategy_solve_rational(game, nash_callback) - else: - equilibria = libgbt._enummixed_strategy_solve_double(game, nash_callback) - return NashComputationResult( - game=game, - method="enummixed", - rational=rational, - use_strategic=True, - equilibria=equilibria - ) + return libgbt._enummixed_strategy_solve_rational(game, nash_callback) + return libgbt._enummixed_strategy_solve_double(game, nash_callback) def lcp_solve( @@ -309,7 +266,7 @@ def lcp_solve( nash_callback: Callable[ [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None ] | None = None, -) -> NashComputationResult: +) -> LcpStrategyResult | LcpBehaviorResult: """Compute Nash equilibria of a two-player game using :ref:`linear complementarity programming `. @@ -343,8 +300,10 @@ def lcp_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : LcpStrategyResult or LcpBehaviorResult + The result represented as an ``LcpStrategyResult`` object when solving on the + strategic representation, or an ``LcpBehaviorResult`` object when solving on + the extensive representation. Raises ------ @@ -367,25 +326,13 @@ def lcp_solve( use_strategic = not game.is_tree or use_strategic if use_strategic: if rational: - equilibria = libgbt._lcp_strategy_solve_rational( + return libgbt._lcp_strategy_solve_rational( game, stop_after, max_depth or 0, nash_callback ) - else: - equilibria = libgbt._lcp_strategy_solve_double( - game, stop_after, max_depth or 0, nash_callback - ) - elif rational: - equilibria = libgbt._lcp_behavior_solve_rational(game, nash_callback) - else: - equilibria = libgbt._lcp_behavior_solve_double(game, nash_callback) - return NashComputationResult( - game=game, - method="lcp", - rational=rational, - use_strategic=use_strategic, - equilibria=equilibria, - parameters={"stop_after": stop_after, "max_depth": max_depth} - ) + return libgbt._lcp_strategy_solve_double(game, stop_after, max_depth or 0, nash_callback) + if rational: + return libgbt._lcp_behavior_solve_rational(game, nash_callback) + return libgbt._lcp_behavior_solve_double(game, nash_callback) def lp_solve( @@ -395,7 +342,7 @@ def lp_solve( nash_callback: Callable[ [libgbt.MixedStrategyProfile | libgbt.MixedBehaviorProfile], None ] | None = None, -) -> NashComputationResult: +) -> LpResult: """Compute Nash equilibria of a two-player constant-sum game using :ref:`linear programming `. @@ -420,8 +367,8 @@ def lp_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : LpResult + The result represented as an ``LpResult`` object. Raises ------ @@ -431,20 +378,11 @@ def lp_solve( use_strategic = not game.is_tree or use_strategic if use_strategic: if rational: - equilibria = libgbt._lp_strategy_solve_rational(game, nash_callback) - else: - equilibria = libgbt._lp_strategy_solve_double(game, nash_callback) - elif rational: - equilibria = libgbt._lp_behavior_solve_rational(game, nash_callback) - else: - equilibria = libgbt._lp_behavior_solve_double(game, nash_callback) - return NashComputationResult( - game=game, - method="lp", - rational=rational, - use_strategic=use_strategic, - equilibria=equilibria - ) + return libgbt._lp_strategy_solve_rational(game, nash_callback) + return libgbt._lp_strategy_solve_double(game, nash_callback) + if rational: + return libgbt._lp_behavior_solve_rational(game, nash_callback) + return libgbt._lp_behavior_solve_double(game, nash_callback) def liap_solve( @@ -455,8 +393,8 @@ def liap_solve( event_callback: Callable[ [libgbt.LiapStartEvent | libgbt.LiapEndEvent], None ] | None = None, -) -> NashComputationResult: - """Compute approximate Nash equilibria of a game using +) -> LiapResult: + """Compute an approximate Nash equilibrium of a game using :ref:`Lyapunov function minimization `. .. versionchanged:: 16.2.0 @@ -509,24 +447,15 @@ def liap_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : LiapResult + The result represented as a ``LiapResult`` object. """ if maxregret <= 0.0: raise ValueError("liap_solve(): maxregret argument must be positive") - start = start.as_float() - equilibria = libgbt._liap_strategy_solve( - start, maxregret=maxregret, maxiter=maxiter, + return libgbt._liap_strategy_solve( + start.as_float(), maxregret=maxregret, maxiter=maxiter, nash_callback=nash_callback, event_callback=event_callback ) - return NashComputationResult( - game=start.game, - method="liap", - rational=False, - use_strategic=True, - equilibria=equilibria, - parameters={"start": start, "maxregret": maxregret, "maxiter": maxiter} - ) def liap_agent_solve( @@ -537,8 +466,8 @@ def liap_agent_solve( event_callback: Callable[ [libgbt.LiapStartEvent | libgbt.LiapEndEvent], None ] | None = None, -) -> NashComputationResult: - """Compute approximate agent Nash equilibria of a game using +) -> LiapResult: + """Compute an approximate agent Nash equilibrium of a game using :ref:`Lyapunov function minimization `. .. versionadded:: 16.5.0 @@ -581,24 +510,15 @@ def liap_agent_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : LiapResult + The result represented as a ``LiapResult`` object. """ if maxregret <= 0.0: raise ValueError("liap_agent_solve(): maxregret argument must be positive") - start = start.as_float() - equilibria = libgbt._liap_behavior_solve( - start, maxregret=maxregret, maxiter=maxiter, + return libgbt._liap_behavior_solve( + start.as_float(), maxregret=maxregret, maxiter=maxiter, nash_callback=nash_callback, event_callback=event_callback ) - return NashComputationResult( - game=start.game, - method="liap-agent", - rational=False, - use_strategic=False, - equilibria=equilibria, - parameters={"start": start, "maxregret": maxregret, "maxiter": maxiter} - ) def simpdiv_solve( @@ -610,8 +530,8 @@ def simpdiv_solve( event_callback: Callable[ [libgbt.SimpdivStartEvent | libgbt.SimpdivRefinementEvent], None ] | None = None, -) -> NashComputationResult: - """Compute Nash equilibria of a game using :ref:`simplicial +) -> SimpdivResult: + """Compute a Nash equilibrium of a game using :ref:`simplicial subdivision `. .. versionchanged:: 16.2.0 @@ -656,8 +576,8 @@ def simpdiv_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : SimpdivResult + The result represented as a ``SimpdivResult`` object. """ if not isinstance(refine, int) or refine < 2: raise ValueError("simpdiv_solve(): refine must be an integer no less than 2") @@ -667,17 +587,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( + return libgbt._simpdiv_strategy_solve( start, maxregret, refine, leash or 0, nash_callback, event_callback ) - return NashComputationResult( - game=start.game, - method="simpdiv", - rational=True, - use_strategic=True, - equilibria=equilibria, - parameters={"start": start, "maxregret": maxregret, "refine": refine, "leash": leash} - ) def ipa_solve( @@ -686,8 +598,8 @@ def ipa_solve( event_callback: Callable[ [libgbt.IPAStepEvent | libgbt.IPATerminationEvent], None ] | None = None, -) -> NashComputationResult: - """Compute Nash equilibria of a game using :ref:`iterated polymatrix +) -> IPAResult: + """Compute a Nash equilibrium of a game using :ref:`iterated polymatrix approximation `. Parameters @@ -728,18 +640,11 @@ def ipa_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : IPAResult + The result represented as an ``IPAResult`` object. """ - game, perturbation = _normalize_perturbation(perturbation) - return NashComputationResult( - game=game, - method="ipa", - rational=False, - use_strategic=True, - parameters={"perturbation": perturbation}, - equilibria=libgbt._ipa_strategy_solve(perturbation, nash_callback, event_callback), - ) + _, perturbation = _normalize_perturbation(perturbation) + return libgbt._ipa_strategy_solve(perturbation, nash_callback, event_callback) def gnm_solve( @@ -758,7 +663,7 @@ def gnm_solve( ], None, ] | None = None, -) -> NashComputationResult: +) -> GNMResult: """Compute Nash equilibria of a game using :ref:`a global Newton method `. @@ -832,10 +737,10 @@ def gnm_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : GNMResult + The result represented as a ``GNMResult`` object. """ - game, perturbation = _normalize_perturbation(perturbation) + _, perturbation = _normalize_perturbation(perturbation) if end_lambda >= 0.0: raise ValueError(f"end_lambda must be a negative number; got {end_lambda}") if steps <= 0: @@ -849,20 +754,9 @@ def gnm_solve( f"local_newton_maxits must be a positive integer; got {local_newton_maxits}" ) try: - return NashComputationResult( - game=game, - method="gnm", - rational=False, - use_strategic=True, - parameters={"perturbation": perturbation, - "end_lambda": end_lambda, - "steps": steps, - "local_newton_interval": local_newton_interval, - "local_newton_maxits": local_newton_maxits}, - equilibria=libgbt._gnm_strategy_solve(perturbation, end_lambda, - steps, - local_newton_interval, local_newton_maxits, - nash_callback, event_callback) + return libgbt._gnm_strategy_solve( + perturbation, end_lambda, steps, local_newton_interval, local_newton_maxits, + nash_callback, event_callback ) except RuntimeError as e: if "at least one nonzero" in str(e): @@ -909,7 +803,7 @@ def enumpoly_solve( ], None, ] | None = None, -) -> NashComputationResult: +) -> EnumPolyResult: """:ref:`Compute Nash equilibria by enumerating all support profiles of strategies or actions, and for each support finding all totally-mixed equilibria of the game over that support. ` @@ -967,8 +861,8 @@ def enumpoly_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : EnumPolyResult + The result represented as an ``EnumPolyResult`` object. Raises ------ @@ -1003,32 +897,24 @@ def enumpoly_solve( "with phcpack_path" ) equilibria = nashphc.phcpack_solve(game, phcpack_path, maxregret) - return NashComputationResult( + return EnumPolyResult( game=game, - method="enumpoly", rational=False, use_strategic=True, - parameters={"stop_after": stop_after, "maxregret": maxregret, - "phcpack_path": phcpack_path}, + stop_after=stop_after, + maxregret=maxregret, + max_rectangles=None, + phcpack_path=phcpack_path, equilibria=equilibria, + success=True, ) if use_strategic: - equilibria = libgbt._enumpoly_strategy_solve( - game, stop_after, maxregret, max_rectangles, nash_callback, event_callback - ) - else: - equilibria = libgbt._enumpoly_behavior_solve( + return libgbt._enumpoly_strategy_solve( game, stop_after, maxregret, max_rectangles, nash_callback, event_callback ) - return NashComputationResult( - game=game, - method="enumpoly", - rational=False, - use_strategic=use_strategic, - parameters={"stop_after": stop_after, "maxregret": maxregret, - "max_rectangles": max_rectangles}, - equilibria=equilibria, + return libgbt._enumpoly_behavior_solve( + game, stop_after, maxregret, max_rectangles, nash_callback, event_callback ) @@ -1041,8 +927,8 @@ def logit_solve( event_callback: Callable[ [libgbt.LogitQREMixedStrategyProfile | libgbt.LogitQREMixedBehaviorProfile], None ] | None = None, -) -> NashComputationResult: - """Compute Nash equilibria of a game using :ref:`the logit quantal response +) -> LogitResult: + """Compute a Nash equilibrium of a game using :ref:`the logit quantal response equilibrium correspondence `. Returns an approximation to the limiting point on the principal branch of @@ -1083,8 +969,8 @@ def logit_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : LogitResult + The result represented as a ``LogitResult`` object. """ if maxregret <= 0.0: raise ValueError("logit_solve(): maxregret argument must be positive") @@ -1094,29 +980,16 @@ def logit_solve( raise ValueError("logit_solve(): max_accel argument must be at least 1.0") use_strategic = not game.is_tree or use_strategic if use_strategic: - 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, event_callback - ) - return NashComputationResult( - game=game, - method="logit", - rational=False, - use_strategic=use_strategic, - equilibria=equilibria, - parameters={"first_step": first_step, "max_accel": max_accel}, - ) + return libgbt._logit_strategy_solve(game, maxregret, first_step, max_accel, event_callback) + return libgbt._logit_behavior_solve(game, maxregret, first_step, max_accel, event_callback) def hp_solve( prior: libgbt.MixedStrategyProfileDouble, maxregret: float = 1.0e-8, event_callback: Callable[[libgbt.HPStepEvent], None] | None = None, -) -> NashComputationResult: - """Compute Nash equilibria of a game using :cite:p:`HerPee01` +) -> HPResult: + """Compute a Nash equilibrium of a game using :cite:p:`HerPee01` Returns an approximation to the limiting point on the principal branch of the homotopy path for the game. @@ -1140,17 +1013,9 @@ def hp_solve( Returns ------- - res : NashComputationResult - The result represented as a ``NashComputationResult`` object. + res : HPResult + The result represented as an ``HPResult`` object. """ if maxregret <= 0.0: raise ValueError("hp_solve(): maxregret argument must be positive") - equilibria = libgbt._hp_strategy_solve(prior, maxregret, event_callback) - return NashComputationResult( - game=prior.game, - method="hp", - rational=False, - use_strategic=True, - equilibria=equilibria, - parameters={"maxregret": maxregret}, - ) + return libgbt._hp_strategy_solve(prior, maxregret, event_callback) diff --git a/src/solvers/enummixed/enummixed.h b/src/solvers/enummixed/enummixed.h index cf65a6d68a..dcfb1e1984 100644 --- a/src/solvers/enummixed/enummixed.h +++ b/src/solvers/enummixed/enummixed.h @@ -48,6 +48,7 @@ template class EnumMixedStrategySolution { Game m_game; std::list> m_extremeEquilibria; + bool success{true}; /// Representation of the graph connecting the extreme equilibria ///@{ @@ -67,13 +68,21 @@ EnumMixedStrategySolveDetailed(const Game &p_game, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, const CancelToken &p_cancel = CancelToken()); +/// @brief The result of enumerating all extreme mixed-strategy Nash equilibria of a +/// two-player game +template struct EnumMixedStrategyResult { + std::list> equilibria; + bool success{true}; +}; + template -std::list> +EnumMixedStrategyResult EnumMixedStrategySolve(const Game &p_game, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, const CancelToken &p_cancel = CancelToken()) { - return EnumMixedStrategySolveDetailed(p_game, p_onEquilibrium, p_cancel)->m_extremeEquilibria; + const auto solution = EnumMixedStrategySolveDetailed(p_game, p_onEquilibrium, p_cancel); + return {solution->m_extremeEquilibria, solution->success}; } } // end namespace Gambit::Nash diff --git a/src/solvers/enumpoly/efgpoly.cc b/src/solvers/enumpoly/efgpoly.cc index d78225f1fa..a4472e54f3 100644 --- a/src/solvers/enumpoly/efgpoly.cc +++ b/src/solvers/enumpoly/efgpoly.cc @@ -207,7 +207,7 @@ std::list> SolveSupport(const BehaviorSupportProfil catch (const SingularMatrixException &) { p_isSingular = true; } - catch (const std::domain_error &) { + catch (const RootOutsideRectangleException &) { p_isSingular = true; } @@ -227,7 +227,7 @@ std::list> SolveSupport(const BehaviorSupportProfil namespace Gambit::Nash { -std::list> +EnumPolyBehaviorResult EnumPolyBehaviorSolve(const Game &p_game, std::optional p_stopAfter, double p_maxregret, size_t p_maxRectangles, BehaviorCallbackType p_onEquilibrium, EnumPolyEventCallbackType p_onEvent, @@ -271,7 +271,7 @@ EnumPolyBehaviorSolve(const Game &p_game, std::optional p_stopAfter, dou break; } } - return ret; + return {ret, true}; } } // namespace Gambit::Nash diff --git a/src/solvers/enumpoly/enumpoly.h b/src/solvers/enumpoly/enumpoly.h index 03f37781bf..997d1bd84f 100644 --- a/src/solvers/enumpoly/enumpoly.h +++ b/src/solvers/enumpoly/enumpoly.h @@ -62,7 +62,13 @@ template void NullEnumPolyEventCallback(const EnumPolyEvent> +/// @brief The result of enumerating Nash equilibria of a strategic game via enumpoly +struct EnumPolyStrategyResult { + std::list> equilibria; + bool success{true}; +}; + +EnumPolyStrategyResult EnumPolyStrategySolve(const Game &p_game, std::optional p_stopAfter, double p_maxregret, size_t p_maxRectangles = kDefaultEnumPolyMaxRectangles, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, @@ -70,7 +76,13 @@ EnumPolyStrategySolve(const Game &p_game, std::optional p_stopAfter, dou NullEnumPolyEventCallback, const CancelToken &p_cancel = CancelToken()); -std::list> +/// @brief The result of enumerating Nash equilibria of an extensive game via enumpoly +struct EnumPolyBehaviorResult { + std::list> equilibria; + bool success{true}; +}; + +EnumPolyBehaviorResult EnumPolyBehaviorSolve(const Game &, std::optional p_stopAfter, double p_maxregret, size_t p_maxRectangles = kDefaultEnumPolyMaxRectangles, BehaviorCallbackType p_onEquilibrium = NullBehaviorCallback, diff --git a/src/solvers/enumpoly/nfgpoly.cc b/src/solvers/enumpoly/nfgpoly.cc index 6c99574392..89301e46a4 100644 --- a/src/solvers/enumpoly/nfgpoly.cc +++ b/src/solvers/enumpoly/nfgpoly.cc @@ -126,8 +126,7 @@ EnumPolyStrategySupportSolve(const StrategySupportProfile &support, bool &is_sin catch (const SingularMatrixException &) { is_singular = true; } - catch (const std::domain_error &) { - // std::cerr << "Assertion warning: " << e.what() << std::endl; + catch (const RootOutsideRectangleException &) { is_singular = true; } @@ -141,7 +140,7 @@ EnumPolyStrategySupportSolve(const StrategySupportProfile &support, bool &is_sin return solutions; } -std::list> +EnumPolyStrategyResult EnumPolyStrategySolve(const Game &p_game, std::optional p_stopAfter, double p_maxregret, size_t p_maxRectangles, StrategyCallbackType p_onEquilibrium, EnumPolyEventCallbackType p_onEvent, @@ -189,7 +188,7 @@ EnumPolyStrategySolve(const Game &p_game, std::optional p_stopAfter, dou break; } } - return ret; + return {ret, true}; } } // namespace Gambit::Nash diff --git a/src/solvers/enumpoly/polysolver.cc b/src/solvers/enumpoly/polysolver.cc index 8bd709ad58..8ecad10aba 100644 --- a/src/solvers/enumpoly/polysolver.cc +++ b/src/solvers/enumpoly/polysolver.cc @@ -129,8 +129,7 @@ double PolynomialSystemSolver::MaxDistanceFromPointToVertexAfterTransformation( // important for the case when a solution may be found on the boundary // of the rectangle but be slightly outside due to numerical error. if (!r.Contains(p, 1.0e-8)) { - throw std::domain_error( - "Point not in rectangle in MaxDistanceFromPointToVertexAfterTransformation."); + throw RootOutsideRectangleException(); } Array bottom(GetDimension()), top(GetDimension()); std::fill(bottom.begin(), bottom.end(), 1); diff --git a/src/solvers/enumpoly/polysolver.h b/src/solvers/enumpoly/polysolver.h index 1e427a795c..18f2d609a5 100644 --- a/src/solvers/enumpoly/polysolver.h +++ b/src/solvers/enumpoly/polysolver.h @@ -23,6 +23,8 @@ #ifndef GAMBIT_SOLVERS_ENUMPOLY_POLYSOLVER_H #define GAMBIT_SOLVERS_ENUMPOLY_POLYSOLVER_H +#include + #include "core/cancel.h" #include "core/core.h" #include "rectangle.h" @@ -31,6 +33,19 @@ namespace Gambit { +/// @brief Thrown by PolynomialSystemSolver when a candidate root, after refinement, turns out +/// to lie outside the rectangle being searched. This is a local degeneracy of the +/// search at that point, akin to a singular Jacobian, not evidence of a defect +/// elsewhere in the solver. +class RootOutsideRectangleException final : public std::domain_error { +public: + RootOutsideRectangleException() + : std::domain_error("Point not in rectangle after Newton refinement") + { + } + ~RootOutsideRectangleException() noexcept override = default; +}; + /// @brief Find the roots of a system of polynomials and inequalities, with /// equal numbers of equations and unknowns, that lie inside a given /// rectangle. diff --git a/src/solvers/enumpure/enumpure.h b/src/solvers/enumpure/enumpure.h index 108f90dad8..744fc474e4 100644 --- a/src/solvers/enumpure/enumpure.h +++ b/src/solvers/enumpure/enumpure.h @@ -42,11 +42,17 @@ inline bool IsNash(const PureStrategyProfile &p_profile) return true; } +/// @brief The result of enumerating pure-strategy Nash equilibria of a game +struct EnumPureStrategyResult { + std::list> equilibria; + bool success{true}; +}; + /// /// Enumerate pure-strategy Nash equilibria of a game. By definition, /// pure-strategy equilibrium uses the strategic representation of a game. /// -inline std::list> EnumPureStrategySolve( +inline EnumPureStrategyResult EnumPureStrategySolve( const Game &p_game, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, const CancelToken &p_cancel = CancelToken()) @@ -55,15 +61,15 @@ inline std::list> EnumPureStrategySolve( throw UndefinedException( "Computing equilibria of games with imperfect recall is not supported."); } - std::list> solutions; + EnumPureStrategyResult result; for (const auto &profile : StrategyContingencies(p_game)) { p_cancel.Check(); if (IsNash(profile)) { - solutions.push_back(profile->ToMixedStrategyProfile()); - p_onEquilibrium(solutions.back()); + result.equilibria.push_back(profile->ToMixedStrategyProfile()); + p_onEquilibrium(result.equilibria.back()); } } - return solutions; + return result; } inline bool IsAgentNash(const PureBehaviorProfile &p_profile) @@ -83,6 +89,12 @@ inline bool IsAgentNash(const PureBehaviorProfile &p_profile) return true; } +/// @brief The result of enumerating pure-strategy agent Nash equilibria of a game +struct EnumPureAgentResult { + std::list> equilibria; + bool success{true}; +}; + /// /// Enumerate pure-strategy agent Nash equilibria of a game. This uses /// the extensive representation. Agent Nash equilibria are not necessarily @@ -90,19 +102,19 @@ inline bool IsAgentNash(const PureBehaviorProfile &p_profile) /// set (rather than possible deviations by the same player at multiple /// information sets. /// -inline std::list> +inline EnumPureAgentResult EnumPureAgentSolve(const Game &p_game, BehaviorCallbackType p_onEquilibrium = NullBehaviorCallback) { - std::list> solutions; + EnumPureAgentResult result; const BehaviorSupportProfile support(p_game); for (auto citer : BehaviorContingencies(BehaviorSupportProfile(p_game))) { if (IsAgentNash(citer)) { - solutions.push_back(citer.ToMixedBehaviorProfile()); - p_onEquilibrium(solutions.back()); + result.equilibria.push_back(citer.ToMixedBehaviorProfile()); + p_onEquilibrium(result.equilibria.back()); } } - return solutions; + return result; } } // end namespace Gambit::Nash diff --git a/src/solvers/gnm/gnm.cc b/src/solvers/gnm/gnm.cc index de97f21f79..8e2146ba02 100644 --- a/src/solvers/gnm/gnm.cc +++ b/src/solvers/gnm/gnm.cc @@ -30,11 +30,11 @@ using namespace Gambit::gametracer; namespace { -std::list> -Solve(const Game &p_game, const std::shared_ptr &p_rep, const cvector &p_pert, - double p_lambdaEnd, int p_steps, int p_localNewtonInterval, int p_localNewtonMaxits, - Nash::StrategyCallbackType p_onEquilibrium, Nash::GNMEventCallbackType p_onEvent, - const CancelToken &p_cancel) +Nash::GNMStrategyResult Solve(const Game &p_game, const std::shared_ptr &p_rep, + const cvector &p_pert, double p_lambdaEnd, int p_steps, + int p_localNewtonInterval, int p_localNewtonMaxits, + Nash::StrategyCallbackType p_onEquilibrium, + Nash::GNMEventCallbackType p_onEvent, const CancelToken &p_cancel) { const double FUZZ = 1e-12; const bool WOBBLE = false; @@ -73,17 +73,20 @@ Solve(const Game &p_game, const std::shared_ptr &p_rep, const cvector & for (const auto &answer : result.equilibria) { eqa.push_back(ToProfile(p_game, answer)); } - return eqa; + const bool success = result.reason == GNMTerminationReason::NoMoreBoundaries || + result.reason == GNMTerminationReason::NoNextBoundary || + result.reason == GNMTerminationReason::LambdaOutOfRange; + return {eqa, success, result.reason}; } } // namespace namespace Gambit::Nash { -std::list> -GNMStrategySolve(const Game &p_game, double p_lambdaEnd, int p_steps, int p_localNewtonInterval, - int p_localNewtonMaxits, StrategyCallbackType p_onEquilibrium, - GNMEventCallbackType p_onEvent, const CancelToken &p_cancel) +GNMStrategyResult GNMStrategySolve(const Game &p_game, double p_lambdaEnd, int p_steps, + int p_localNewtonInterval, int p_localNewtonMaxits, + StrategyCallbackType p_onEquilibrium, + GNMEventCallbackType p_onEvent, const CancelToken &p_cancel) { if (!p_game->IsPerfectRecall()) { throw UndefinedException( @@ -103,11 +106,10 @@ GNMStrategySolve(const Game &p_game, double p_lambdaEnd, int p_steps, int p_loca p_localNewtonMaxits, p_onEquilibrium, p_onEvent, p_cancel); } -std::list> -GNMStrategySolve(const MixedStrategyProfile &p_pert, double p_lambdaEnd, int p_steps, - int p_localNewtonInterval, int p_localNewtonMaxits, - StrategyCallbackType p_onEquilibrium, GNMEventCallbackType p_onEvent, - const CancelToken &p_cancel) +GNMStrategyResult GNMStrategySolve(const MixedStrategyProfile &p_pert, double p_lambdaEnd, + int p_steps, int p_localNewtonInterval, int p_localNewtonMaxits, + StrategyCallbackType p_onEquilibrium, + GNMEventCallbackType p_onEvent, const CancelToken &p_cancel) { if (!p_pert.GetGame()->IsPerfectRecall()) { throw UndefinedException( diff --git a/src/solvers/gnm/gnm.h b/src/solvers/gnm/gnm.h index 029d09dc98..18752050d7 100644 --- a/src/solvers/gnm/gnm.h +++ b/src/solvers/gnm/gnm.h @@ -60,7 +60,17 @@ using GNMEventCallbackType = std::function; inline void NullGNMEventCallback(const GNMEvent &) {} -std::list> +/// @brief The result of computing Nash equilibria via the global Newton method +struct GNMStrategyResult { + std::list> equilibria; + bool success{true}; + /// Why the path-following run terminated; this is the same event reported to + /// GNMEventCallbackType via GNMTerminationEvent. A value other than NoMoreBoundaries, + /// NoNextBoundary, or LambdaOutOfRange indicates a numerical breakdown, and success is false. + gametracer::GNMTerminationReason reason{gametracer::GNMTerminationReason::NoMoreBoundaries}; +}; + +GNMStrategyResult GNMStrategySolve(const Game &p_game, double p_lambdaEnd, int p_steps, int p_localNewtonInterval, int p_localNewtonMaxits, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, @@ -69,7 +79,7 @@ GNMStrategySolve(const Game &p_game, double p_lambdaEnd, int p_steps, int p_loca /// @brief Compute the mixed strategy equilibria accessible via the initial ray determined /// by \p p_profile using the Global Newton method -std::list> +GNMStrategyResult GNMStrategySolve(const MixedStrategyProfile &p_profile, double p_lambdaEnd, int p_steps, int p_localNewtonInterval, int p_localNewtonMaxits, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, diff --git a/src/solvers/hp/hp.cc b/src/solvers/hp/hp.cc index 95e640b875..3e0c80b942 100644 --- a/src/solvers/hp/hp.cc +++ b/src/solvers/hp/hp.cc @@ -26,13 +26,10 @@ #include "solvers/path/path.h" namespace Gambit::Nash { -std::list> -HPStrategySolve(const MixedStrategyProfile &p_prior, double p_maxRegret, - StrategyCallbackType p_onEquilibrium, HPEventCallbackType p_onEvent, - const CancelToken &p_cancel) +HPStrategyResult HPStrategySolve(const MixedStrategyProfile &p_prior, double p_maxRegret, + StrategyCallbackType p_onEquilibrium, + HPEventCallbackType p_onEvent, const CancelToken &p_cancel) { - std::list> equilibria; - HPEquationSystem system(p_prior); Vector x = system.ComputeInitialPoint(); @@ -93,6 +90,10 @@ HPStrategySolve(const MixedStrategyProfile &p_prior, double p_maxRegret, }, criterion_function, NullCriterionBracketFunction, p_cancel); + if (!tracing_result.status) { + return {std::nullopt, false, HPTerminationReason::TraceFailed}; + } + const PolishResult polishing_result = PolishPoint( [&system](const Vector &point, Vector &lhs) { system.GetValue(point, lhs); }, [&system](const Vector &point, Matrix &jac) { @@ -105,14 +106,13 @@ HPStrategySolve(const MixedStrategyProfile &p_prior, double p_maxRegret, }); if (!polishing_result.status) { - return {}; + return {std::nullopt, false, HPTerminationReason::PolishFailed}; } const MixedStrategyProfile equilibrium = system.ExtractEquilibrium(x); if (equilibrium.GetMaxRegret() > p_maxRegret) { - return {}; + return {std::nullopt, false, HPTerminationReason::RegretTargetNotReached}; } p_onEquilibrium(equilibrium); - equilibria.push_back(equilibrium); - return equilibria; + return {equilibrium, true, HPTerminationReason::Converged}; } } // namespace Gambit::Nash diff --git a/src/solvers/hp/hp.h b/src/solvers/hp/hp.h index b600a1e020..5b12e57acd 100644 --- a/src/solvers/hp/hp.h +++ b/src/solvers/hp/hp.h @@ -25,6 +25,7 @@ #include #include +#include #include #include "solvers/nash.h" @@ -42,9 +43,25 @@ using HPEventCallbackType = std::function; inline void NullHPEventCallback(const HPEvent &) {} +/// @brief Why a call to HPStrategySolve() did not return an accepted equilibrium +enum class HPTerminationReason { + Converged, // the accepted equilibrium satisfies maxRegret + TraceFailed, // the homotopy path tracer could not make progress towards t=1 + PolishFailed, // the tracer reached t=1, but polishing to reduce regret there did not converge + RegretTargetNotReached, // polishing converged, but the resulting point still exceeds maxRegret +}; + +/// @brief The result of computing a Nash equilibrium via the homotopy method of +/// Herings and Peeters (2001) +struct HPStrategyResult { + std::optional> equilibrium; + bool success{false}; + HPTerminationReason reason{HPTerminationReason::TraceFailed}; +}; + /// @brief Compute a Nash equilibrium of a game using the homotopy method of /// Herings and Peeters (2001) -std::list> +HPStrategyResult HPStrategySolve(const MixedStrategyProfile &p_prior, double p_maxRegret = 1.0e-8, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, HPEventCallbackType p_onEvent = NullHPEventCallback, diff --git a/src/solvers/ipa/ipa.cc b/src/solvers/ipa/ipa.cc index 725d43a600..22c6e20aad 100644 --- a/src/solvers/ipa/ipa.cc +++ b/src/solvers/ipa/ipa.cc @@ -49,9 +49,9 @@ std::string IPATerminationMessage(const IPAResult &p_result) namespace Gambit::Nash { -std::list> -IPAStrategySolve(const Game &p_game, StrategyCallbackType p_onEquilibrium, - IPAEventCallbackType p_onEvent, const CancelToken &p_cancel) +IPAStrategyResult IPAStrategySolve(const Game &p_game, + StrategyCallbackType p_onEquilibrium, + IPAEventCallbackType p_onEvent, const CancelToken &p_cancel) { MixedStrategyProfile pert = p_game->NewMixedStrategyProfile(0.0); for (const auto &player : p_game->GetPlayers()) { @@ -65,10 +65,9 @@ IPAStrategySolve(const Game &p_game, StrategyCallbackType p_onEquilibriu return IPAStrategySolve(pert, p_onEquilibrium, p_onEvent, p_cancel); } -std::list> -IPAStrategySolve(const MixedStrategyProfile &p_pert, - StrategyCallbackType p_onEquilibrium, IPAEventCallbackType p_onEvent, - const CancelToken &p_cancel) +IPAStrategyResult IPAStrategySolve(const MixedStrategyProfile &p_pert, + StrategyCallbackType p_onEquilibrium, + IPAEventCallbackType p_onEvent, const CancelToken &p_cancel) { if (!p_pert.GetGame()->IsPerfectRecall()) { throw UndefinedException( @@ -98,24 +97,18 @@ IPAStrategySolve(const MixedStrategyProfile &p_pert, result = IPA(*A, g, zh, ALPHA, EQERR, 100, onStep, p_cancel); p_onEvent( IPATerminationEvent{.reason = result.reason, .message = IPATerminationMessage(result)}); - if (result.reason == IPATerminationReason::Converged) { + if (result.reason != IPATerminationReason::MaxIterationsReached) { + // Either converged, or hit a numerical breakdown that further restarts won't fix. break; } - if (result.reason == IPATerminationReason::NonfiniteStrategy) { - throw std::runtime_error( - "IPA encountered a non-finite strategy profile; the perturbation vector " - "may not be suitable for this game"); - } } if (result.reason != IPATerminationReason::Converged) { - throw std::runtime_error("IPA failed to converge after " + std::to_string(MAX_RESTARTS) + - " restarts"); + return {std::nullopt, false, result.reason}; } - std::list> solutions; - solutions.push_back(ToProfile(game, result.strategy)); - p_onEquilibrium(solutions.back()); - return solutions; + const MixedStrategyProfile equilibrium = ToProfile(game, result.strategy); + p_onEquilibrium(equilibrium); + return {equilibrium, true, IPATerminationReason::Converged}; } } // namespace Gambit::Nash diff --git a/src/solvers/ipa/ipa.h b/src/solvers/ipa/ipa.h index 7db25e1539..db5a09db16 100644 --- a/src/solvers/ipa/ipa.h +++ b/src/solvers/ipa/ipa.h @@ -23,6 +23,7 @@ #ifndef GAMBIT_SOLVERS_IPA_IPA_H #define GAMBIT_SOLVERS_IPA_IPA_H +#include #include #include "solvers/nash.h" @@ -52,13 +53,22 @@ using IPAEventCallbackType = std::function; inline void NullIPAEventCallback(const IPAEvent &) {} -std::list> +/// @brief The result of computing a Nash equilibrium via iterated polymatrix approximation +struct IPAStrategyResult { + std::optional> equilibrium; + bool success{false}; + /// Why the iteration terminated; this is the same reason reported to IPAEventCallbackType + /// via IPATerminationEvent. success is true only when this is Converged. + gametracer::IPATerminationReason reason{gametracer::IPATerminationReason::MaxIterationsReached}; +}; + +IPAStrategyResult IPAStrategySolve(const Game &p_game, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, IPAEventCallbackType p_onEvent = NullIPAEventCallback, const CancelToken &p_cancel = CancelToken()); -std::list> +IPAStrategyResult IPAStrategySolve(const MixedStrategyProfile &p_pert, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, IPAEventCallbackType p_onEvent = NullIPAEventCallback, diff --git a/src/solvers/lcp/efglcp.cc b/src/solvers/lcp/efglcp.cc index 6e50c69020..5fb6c059e3 100644 --- a/src/solvers/lcp/efglcp.cc +++ b/src/solvers/lcp/efglcp.cc @@ -178,9 +178,8 @@ MixedBehaviorProfile GetProfile(const linalg::LemkeTableau &tab, const Vec // as refined by Eaves for degenerate problems, starting from the primary ray. // template -std::list> LcpBehaviorSolve(const Game &p_game, - BehaviorCallbackType p_onEquilibrium, - const CancelToken &p_cancel) +LcpBehaviorResult LcpBehaviorSolve(const Game &p_game, BehaviorCallbackType p_onEquilibrium, + const CancelToken &p_cancel) { if (p_game->NumPlayers() != 2) { throw UndefinedException("Method only valid for two-player games."); @@ -202,14 +201,12 @@ std::list> LcpBehaviorSolve(const Game &p_game, profile.UndefinedToCentroid(); p_onEquilibrium(profile); - std::list> equilibria; - equilibria.push_back(profile); - return equilibria; + return {profile, true}; } -template std::list> -LcpBehaviorSolve(const Game &, BehaviorCallbackType, const CancelToken &); -template std::list> -LcpBehaviorSolve(const Game &, BehaviorCallbackType, const CancelToken &); +template LcpBehaviorResult LcpBehaviorSolve(const Game &, BehaviorCallbackType, + const CancelToken &); +template LcpBehaviorResult LcpBehaviorSolve(const Game &, BehaviorCallbackType, + const CancelToken &); } // end namespace Gambit::Nash diff --git a/src/solvers/lcp/lcp.h b/src/solvers/lcp/lcp.h index 328fc4a266..b617f61ea6 100644 --- a/src/solvers/lcp/lcp.h +++ b/src/solvers/lcp/lcp.h @@ -29,14 +29,26 @@ namespace Gambit::Nash { +/// @brief The result of computing Nash equilibria of a strategic game via LCP +template struct LcpStrategyResult { + std::list> equilibria; + bool success{true}; +}; + template -std::list> +LcpStrategyResult LcpStrategySolve(const Game &p_game, std::optional p_stopAfter, int p_maxDepth, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, const CancelToken &p_cancel = CancelToken()); +/// @brief The result of computing a Nash equilibrium of an extensive game via LCP +template struct LcpBehaviorResult { + std::optional> equilibrium; + bool success{true}; +}; + template -std::list> +LcpBehaviorResult LcpBehaviorSolve(const Game &p_game, BehaviorCallbackType p_onEquilibrium = NullBehaviorCallback, const CancelToken &p_cancel = CancelToken()); diff --git a/src/solvers/lcp/nfglcp.cc b/src/solvers/lcp/nfglcp.cc index ab77ac3d8b..37a7e0dbe7 100644 --- a/src/solvers/lcp/nfglcp.cc +++ b/src/solvers/lcp/nfglcp.cc @@ -296,21 +296,20 @@ std::list> NashLcpStrategySolver::Solve(const Game &p } template -std::list> -LcpStrategySolve(const Game &p_game, std::optional p_stopAfter, int p_maxDepth, - StrategyCallbackType p_onEquilibrium, const CancelToken &p_cancel) +LcpStrategyResult LcpStrategySolve(const Game &p_game, std::optional p_stopAfter, + int p_maxDepth, StrategyCallbackType p_onEquilibrium, + const CancelToken &p_cancel) { - return NashLcpStrategySolver(p_stopAfter, p_maxDepth, p_onEquilibrium, p_cancel) - .Solve(p_game); + return { + NashLcpStrategySolver(p_stopAfter, p_maxDepth, p_onEquilibrium, p_cancel).Solve(p_game), + true}; } -template std::list> LcpStrategySolve(const Game &, - std::optional, int, - StrategyCallbackType, - const CancelToken &); -template std::list> LcpStrategySolve(const Game &, - std::optional, int, - StrategyCallbackType, - const CancelToken &); +template LcpStrategyResult LcpStrategySolve(const Game &, std::optional, int, + StrategyCallbackType, + const CancelToken &); +template LcpStrategyResult LcpStrategySolve(const Game &, std::optional, int, + StrategyCallbackType, + const CancelToken &); } // end namespace Gambit::Nash diff --git a/src/solvers/liap/efgliap.cc b/src/solvers/liap/efgliap.cc index bf59b01228..e4c9d727b4 100644 --- a/src/solvers/liap/efgliap.cc +++ b/src/solvers/liap/efgliap.cc @@ -127,19 +127,16 @@ MixedBehaviorProfile EnforceNonnegativity(const MixedBehaviorProfile> -LiapAgentSolve(const MixedBehaviorProfile &p_start, double p_maxregret, int p_maxitsN, - BehaviorCallbackType p_onEquilibrium, - LiapEventCallbackType> p_onEvent, - const CancelToken &p_cancel) +LiapAgentResult LiapAgentSolve(const MixedBehaviorProfile &p_start, double p_maxregret, + int p_maxitsN, BehaviorCallbackType p_onEquilibrium, + LiapEventCallbackType> p_onEvent, + const CancelToken &p_cancel) { if (!p_start.GetGame()->IsPerfectRecall()) { throw UndefinedException( "Computing equilibria of games with imperfect recall is not supported."); } - std::list> solutions; - MixedBehaviorProfile p(p_start); p_onEvent(LiapStartEvent>{p}); @@ -150,10 +147,17 @@ LiapAgentSolve(const MixedBehaviorProfile &p_start, double p_maxregret, double fval; minimizer.Set(F, static_cast &>(p), fval, gradient, .001, .00001); + bool minimizerFailed = false; for (int iter = 1; iter <= p_maxitsN; iter++) { p_cancel.Check(); Vector point(p); - if (!minimizer.Iterate(F, point, fval, gradient, dx)) { + try { + if (!minimizer.Iterate(F, point, fval, gradient, dx)) { + break; + } + } + catch (const FunctionMinimizerError &) { + minimizerFailed = true; break; } p = point; @@ -166,12 +170,12 @@ LiapAgentSolve(const MixedBehaviorProfile &p_start, double p_maxregret, const double regret = p2.GetAgentMaxRegret() * F.GetScale(); if (regret < p_maxregret) { p_onEquilibrium(p2); - solutions.push_back(p2); - } - else { - p_onEvent(LiapEndEvent>{p2, regret}); + return {p2, true, LiapTerminationReason::Converged}; } - return solutions; + p_onEvent(LiapEndEvent>{p2, regret}); + return {std::nullopt, false, + minimizerFailed ? LiapTerminationReason::MinimizerFailed + : LiapTerminationReason::RegretTargetNotReached}; } } // namespace Gambit::Nash diff --git a/src/solvers/liap/liap.h b/src/solvers/liap/liap.h index 062c3024ff..a142b6a685 100644 --- a/src/solvers/liap/liap.h +++ b/src/solvers/liap/liap.h @@ -23,6 +23,7 @@ #ifndef GAMBIT_SOLVERS_LIAP_LIAP_H #define GAMBIT_SOLVERS_LIAP_LIAP_H +#include #include #include "solvers/nash.h" @@ -45,14 +46,37 @@ using LiapEventCallbackType = std::function &)>; template void NullLiapEventCallback(const LiapEvent &) {} -std::list> +/// @brief Why a call to LiapStrategySolve()/LiapAgentSolve() did not return an accepted +/// equilibrium +enum class LiapTerminationReason { + Converged, // the accepted point satisfies maxregret + MinimizerFailed, // the underlying line search encountered a numerical breakdown + RegretTargetNotReached, // the iteration limit was reached without regret falling below + // maxregret +}; + +/// @brief The result of minimizing the Lyapunov function on an extensive game +struct LiapAgentResult { + std::optional> equilibrium; + bool success{false}; + LiapTerminationReason reason{LiapTerminationReason::RegretTargetNotReached}; +}; + +LiapAgentResult LiapAgentSolve(const MixedBehaviorProfile &p_start, double p_maxregret, int p_maxitsN, BehaviorCallbackType p_onEquilibrium = NullBehaviorCallback, LiapEventCallbackType> p_onEvent = NullLiapEventCallback>, const CancelToken &p_cancel = CancelToken()); -std::list> +/// @brief The result of minimizing the Lyapunov function on a strategic game +struct LiapStrategyResult { + std::optional> equilibrium; + bool success{false}; + LiapTerminationReason reason{LiapTerminationReason::RegretTargetNotReached}; +}; + +LiapStrategyResult LiapStrategySolve(const MixedStrategyProfile &p_start, double p_maxregret, int p_maxitsN, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, LiapEventCallbackType> p_onEvent = diff --git a/src/solvers/liap/nfgliap.cc b/src/solvers/liap/nfgliap.cc index 2588a689f8..5b46929421 100644 --- a/src/solvers/liap/nfgliap.cc +++ b/src/solvers/liap/nfgliap.cc @@ -133,19 +133,17 @@ MixedStrategyProfile EnforceNonnegativity(const MixedStrategyProfile> -LiapStrategySolve(const MixedStrategyProfile &p_start, double p_maxregret, int p_maxitsN, - StrategyCallbackType p_onEquilibrium, - LiapEventCallbackType> p_onEvent, - const CancelToken &p_cancel) +LiapStrategyResult LiapStrategySolve(const MixedStrategyProfile &p_start, + double p_maxregret, int p_maxitsN, + StrategyCallbackType p_onEquilibrium, + LiapEventCallbackType> p_onEvent, + const CancelToken &p_cancel) { if (!p_start.GetGame()->IsPerfectRecall()) { throw UndefinedException( "Computing equilibria of games with imperfect recall is not supported."); } - std::list> solutions; - MixedStrategyProfile p(p_start); p_onEvent(LiapStartEvent>{p}); @@ -155,10 +153,17 @@ LiapStrategySolve(const MixedStrategyProfile &p_start, double p_maxregre double fval; minimizer.Set(F, p.GetProbVector(), fval, gradient, .001, .00001); + bool minimizerFailed = false; for (int iter = 1; iter <= p_maxitsN; iter++) { p_cancel.Check(); Vector point(p.GetProbVector()); - if (!minimizer.Iterate(F, point, fval, gradient, dx)) { + try { + if (!minimizer.Iterate(F, point, fval, gradient, dx)) { + break; + } + } + catch (const FunctionMinimizerError &) { + minimizerFailed = true; break; } p = point; @@ -171,13 +176,12 @@ LiapStrategySolve(const MixedStrategyProfile &p_start, double p_maxregre const double regret = p.GetMaxRegret() * F.GetScale(); if (regret < p_maxregret) { p_onEquilibrium(p); - solutions.push_back(p); + return {p, true, LiapTerminationReason::Converged}; } - else { - p_onEvent(LiapEndEvent>{p, regret}); - } - - return solutions; + p_onEvent(LiapEndEvent>{p, regret}); + return {std::nullopt, false, + minimizerFailed ? LiapTerminationReason::MinimizerFailed + : LiapTerminationReason::RegretTargetNotReached}; } } // namespace Gambit::Nash diff --git a/src/solvers/logit/efglogit.cc b/src/solvers/logit/efglogit.cc index 093460e0f4..ab51ce40c9 100644 --- a/src/solvers/logit/efglogit.cc +++ b/src/solvers/logit/efglogit.cc @@ -345,7 +345,9 @@ LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, NullCriterionFunction, NullCriterionBracketFunction, p_cancel); const auto &profiles = callback.GetProfiles(); - p_onEquilibrium(profiles.back().GetProfile()); + if (profiles.back().GetProfile().GetAgentMaxRegret() < p_regret) { + p_onEquilibrium(profiles.back().GetProfile()); + } return profiles; } diff --git a/src/solvers/logit/logit.h b/src/solvers/logit/logit.h index a8f0faded2..9fb24ecd94 100644 --- a/src/solvers/logit/logit.h +++ b/src/solvers/logit/logit.h @@ -24,6 +24,7 @@ #define GAMBIT_SOLVERS_LOGIT_LOGIT_H #include +#include #include #include "solvers/nash.h" @@ -92,6 +93,15 @@ template using LogitEventCallbackType = std::function void NullLogitEventCallback(const LogitEvent &) {} +/// @brief Why a call to LogitStrategySolveEquilibrium()/LogitBehaviorSolveEquilibrium() did +/// not return an accepted equilibrium +enum class LogitTerminationReason { + Converged, // the traced point satisfies the requested regret criterion + RegretTargetNotReached, // tracing along the principal branch ended without reaching that + // criterion, whether from stalling near a bifurcation or simply running + // out of path +}; + std::list LogitStrategySolve( const LogitQREMixedStrategyProfile &p_start, double p_regret, PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, @@ -100,6 +110,36 @@ std::list LogitStrategySolve( NullLogitEventCallback, const CancelToken &p_cancel = CancelToken()); +/// @brief The result of tracing the logit QRE correspondence for a strategic game to an +/// accepted approximate Nash equilibrium +struct LogitStrategyResult { + std::optional> equilibrium; + bool success{false}; + LogitTerminationReason reason{LogitTerminationReason::RegretTargetNotReached}; +}; + +/// @brief Trace the principal branch of the logit QRE correspondence for a strategic game, +/// starting at lambda=0, to an approximate Nash equilibrium satisfying \p p_regret +inline LogitStrategyResult LogitStrategySolveEquilibrium( + const LogitQREMixedStrategyProfile &p_start, double p_regret, double p_firstStep, + double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback, + PathTracer::TraceDirection p_direction = PathTracer::TraceDirection::Positive, + const CancelToken &p_cancel = CancelToken()) +{ + const auto profiles = + LogitStrategySolve(p_start, p_regret, p_direction, p_firstStep, p_maxAccel, + Nash::NullStrategyCallback, p_onEvent, p_cancel); + const MixedStrategyProfile &candidate = profiles.back().GetProfile(); + const double scale = p_start.GetGame()->GetMaxPayoff() - p_start.GetGame()->GetMinPayoff(); + const double regret = (scale != 0.0) ? p_regret * scale : p_regret; + if (candidate.GetMaxRegret() > regret) { + return {std::nullopt, false, LogitTerminationReason::RegretTargetNotReached}; + } + return {candidate, true, LogitTerminationReason::Converged}; +} + std::list LogitStrategySolveLambda( const LogitQREMixedStrategyProfile &p_start, const std::list &p_targetLambda, PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, @@ -123,6 +163,36 @@ std::list LogitBehaviorSolve( NullLogitEventCallback, const CancelToken &p_cancel = CancelToken()); +/// @brief The result of tracing the logit QRE correspondence for an extensive game to an +/// accepted approximate Nash equilibrium +struct LogitBehaviorResult { + std::optional> equilibrium; + bool success{false}; + LogitTerminationReason reason{LogitTerminationReason::RegretTargetNotReached}; +}; + +/// @brief Trace the principal branch of the logit QRE correspondence for an extensive game, +/// starting at lambda=0, to an approximate Nash equilibrium satisfying \p p_regret +inline LogitBehaviorResult LogitBehaviorSolveEquilibrium( + const LogitQREMixedBehaviorProfile &p_start, double p_regret, double p_firstStep, + double p_maxAccel, + LogitEventCallbackType p_onEvent = + NullLogitEventCallback, + PathTracer::TraceDirection p_direction = PathTracer::TraceDirection::Positive, + const CancelToken &p_cancel = CancelToken()) +{ + const auto profiles = + LogitBehaviorSolve(p_start, p_regret, p_direction, p_firstStep, p_maxAccel, + Nash::NullBehaviorCallback, p_onEvent, p_cancel); + const MixedBehaviorProfile &candidate = profiles.back().GetProfile(); + const double scale = p_start.GetGame()->GetMaxPayoff() - p_start.GetGame()->GetMinPayoff(); + const double regret = (scale != 0.0) ? p_regret * scale : p_regret; + if (candidate.GetAgentMaxRegret() > regret) { + return {std::nullopt, false, LogitTerminationReason::RegretTargetNotReached}; + } + return {candidate, true, LogitTerminationReason::Converged}; +} + std::list LogitBehaviorSolveLambda( const LogitQREMixedBehaviorProfile &p_start, const std::list &p_targetLambda, PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, diff --git a/src/solvers/logit/nfglogit.cc b/src/solvers/logit/nfglogit.cc index 56fc3cdf08..7703c2a3fc 100644 --- a/src/solvers/logit/nfglogit.cc +++ b/src/solvers/logit/nfglogit.cc @@ -384,7 +384,9 @@ LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, NullCriterionFunction, NullCriterionBracketFunction, p_cancel); const auto &profiles = callback.GetProfiles(); - p_onEquilibrium(profiles.back().GetProfile()); + if (profiles.back().GetProfile().GetMaxRegret() < p_regret) { + p_onEquilibrium(profiles.back().GetProfile()); + } return profiles; } diff --git a/src/solvers/lp/lp.cc b/src/solvers/lp/lp.cc index 2d6f4f4e8b..da08a964be 100644 --- a/src/solvers/lp/lp.cc +++ b/src/solvers/lp/lp.cc @@ -211,9 +211,8 @@ void SolveLP(const Matrix &A, const Vector &b, const Vector &c, int neq } template -std::list> LpBehaviorSolve(const Game &p_game, - BehaviorCallbackType p_onEquilibrium, - const CancelToken &p_cancel) +LpBehaviorResult LpBehaviorSolve(const Game &p_game, BehaviorCallbackType p_onEquilibrium, + const CancelToken &p_cancel) { if (p_game->NumPlayers() != 2) { throw UndefinedException("Method only valid for two-player games."); @@ -238,20 +237,17 @@ std::list> LpBehaviorSolve(const Game &p_game, profile.UndefinedToCentroid(); p_onEquilibrium(profile); - std::list> solution; - solution.push_back(profile); - return solution; + return {profile, true}; } -template std::list> -LpBehaviorSolve(const Game &, BehaviorCallbackType, const CancelToken &); -template std::list> -LpBehaviorSolve(const Game &, BehaviorCallbackType, const CancelToken &); +template LpBehaviorResult LpBehaviorSolve(const Game &, BehaviorCallbackType, + const CancelToken &); +template LpBehaviorResult LpBehaviorSolve(const Game &, BehaviorCallbackType, + const CancelToken &); template -std::list> LpStrategySolve(const Game &p_game, - StrategyCallbackType p_onEquilibrium, - const CancelToken &p_cancel) +LpStrategyResult LpStrategySolve(const Game &p_game, StrategyCallbackType p_onEquilibrium, + const CancelToken &p_cancel) { if (p_game->NumPlayers() != 2) { throw UndefinedException("Method only valid for two-player games."); @@ -303,14 +299,12 @@ std::list> LpStrategySolve(const Game &p_game, eqm[p_game->GetPlayer(2)->GetStrategy(j)] = dual[j]; } p_onEquilibrium(eqm); - std::list> solution; - solution.push_back(eqm); - return solution; + return {eqm, true}; } -template std::list> -LpStrategySolve(const Game &, StrategyCallbackType, const CancelToken &); -template std::list> -LpStrategySolve(const Game &, StrategyCallbackType, const CancelToken &); +template LpStrategyResult LpStrategySolve(const Game &, StrategyCallbackType, + const CancelToken &); +template LpStrategyResult LpStrategySolve(const Game &, StrategyCallbackType, + const CancelToken &); } // end namespace Gambit::Nash diff --git a/src/solvers/lp/lp.h b/src/solvers/lp/lp.h index 0e0bddffad..1a25b29816 100644 --- a/src/solvers/lp/lp.h +++ b/src/solvers/lp/lp.h @@ -23,18 +23,32 @@ #ifndef GAMBIT_SOLVERS_LP_LP_H #define GAMBIT_SOLVERS_LP_LP_H +#include + #include "solvers/nash.h" namespace Gambit::Nash { +/// @brief The result of computing a Nash equilibrium of a strategic game via linear programming +template struct LpStrategyResult { + std::optional> equilibrium; + bool success{true}; +}; + template -std::list> +LpStrategyResult LpStrategySolve(const Game &p_game, StrategyCallbackType p_onEquilibrium = NullStrategyCallback, const CancelToken &p_cancel = CancelToken()); +/// @brief The result of computing a Nash equilibrium of an extensive game via linear programming +template struct LpBehaviorResult { + std::optional> equilibrium; + bool success{true}; +}; + template -std::list> +LpBehaviorResult LpBehaviorSolve(const Game &p_game, BehaviorCallbackType p_onEquilibrium = NullBehaviorCallback, const CancelToken &p_cancel = CancelToken()); diff --git a/src/solvers/simpdiv/simpdiv.cc b/src/solvers/simpdiv/simpdiv.cc index a0e6c7e33b..380bb90826 100644 --- a/src/solvers/simpdiv/simpdiv.cc +++ b/src/solvers/simpdiv/simpdiv.cc @@ -545,15 +545,17 @@ MixedStrategyProfile SimpdivDefaultStart(const Game &p_game) return start; } -std::list> -SimpdivStrategySolve(const MixedStrategyProfile &p_start, const Rational &p_maxregret, - int p_gridResize, int p_leashLength, - StrategyCallbackType p_onEquilibrium, - SimpdivEventCallbackType p_onEvent, const CancelToken &p_cancel) +SimpdivStrategyResult SimpdivStrategySolve(const MixedStrategyProfile &p_start, + const Rational &p_maxregret, int p_gridResize, + int p_leashLength, + StrategyCallbackType p_onEquilibrium, + SimpdivEventCallbackType p_onEvent, + const CancelToken &p_cancel) { - return NashSimpdivStrategySolver(p_gridResize, p_leashLength, p_maxregret, p_onEquilibrium, - p_onEvent, p_cancel) - .Solve(p_start); + const auto solutions = NashSimpdivStrategySolver(p_gridResize, p_leashLength, p_maxregret, + p_onEquilibrium, p_onEvent, p_cancel) + .Solve(p_start); + return {solutions.back(), true}; } } // end namespace Gambit::Nash diff --git a/src/solvers/simpdiv/simpdiv.h b/src/solvers/simpdiv/simpdiv.h index 8949a65184..2883f67c11 100644 --- a/src/solvers/simpdiv/simpdiv.h +++ b/src/solvers/simpdiv/simpdiv.h @@ -23,6 +23,7 @@ #ifndef GAMBIT_SOLVERS_SIMPDIV_SIMPDIV_H #define GAMBIT_SOLVERS_SIMPDIV_SIMPDIV_H +#include #include #include "solvers/nash.h" @@ -57,12 +58,18 @@ inline void NullSimpdivEventCallback(const SimpdivEvent &) {} /// MixedStrategyProfile SimpdivDefaultStart(const Game &p_game); +/// @brief The result of computing a Nash equilibrium via simplicial subdivision +struct SimpdivStrategyResult { + std::optional> equilibrium; + bool success{true}; +}; + /// /// This is a simplicial subdivision algorithm with restart, for finding /// mixed strategy solutions to general finite n-person games. It is based on /// van Der Laan, Talman and van Der Heyden, Math of Oper Res, 1987. /// -std::list> SimpdivStrategySolve( +SimpdivStrategyResult SimpdivStrategySolve( const MixedStrategyProfile &p_start, const Rational &p_maxregret = Rational(1, 10000000), int p_gridResize = 2, int p_leashLength = 0, diff --git a/tests/test_nash.py b/tests/test_nash.py index 7aa09fee70..b8a250cad0 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -29,12 +29,22 @@ def _action_prob(profile: gbt.MixedBehaviorProfile, history: gbt.History, label: return profile[gbt.H.path(*history.actions)][label] +def _equilibria(result: gbt.nash.NashResultBase) -> list: + """Normalize a Nash result's found equilibria to a list, regardless of whether the + underlying method can produce many (`result.equilibria`) or at most one + (`result.equilibrium`). + """ + if hasattr(result, "equilibria"): + return result.equilibria + return [result.equilibrium] if result.equilibrium is not None else [] + + @dataclasses.dataclass class EquilibriumTestCase: """Summarising the data relevant for a test fixture of a call to an equilibrium solver.""" factory: typing.Callable[[], gbt.Game] - solver: typing.Callable[[gbt.Game], gbt.nash.NashComputationResult] + solver: typing.Callable[[gbt.Game], gbt.nash.NashResultBase] expected: list regret_tol: float | gbt.Rational = Q(0) prob_tol: float | gbt.Rational = Q(0) @@ -46,7 +56,7 @@ class EquilibriumTestCaseWithStart: that needs a starting profile.""" factory: typing.Callable[[], gbt.Game] - solver: typing.Callable[[gbt.Game], gbt.nash.NashComputationResult] + solver: typing.Callable[[gbt.Game], gbt.nash.NashResultBase] start_data: None | list expected: list regret_tol: float | gbt.Rational = Q(0) @@ -1608,9 +1618,10 @@ def test_nash_strategy_solver(test_case: EquilibriumTestCase, subtests) -> None: """ game = test_case.factory() result = test_case.solver(game) + equilibria = _equilibria(result) with subtests.test("number of equilibria found"): - assert len(result.equilibria) == len(test_case.expected) - for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): + assert len(equilibria) == len(test_case.expected) + for i, (eq, exp) in enumerate(zip(equilibria, test_case.expected, strict=True)): with subtests.test(eq=i, check="max_regret"): assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="strategy_profile"): @@ -1645,14 +1656,14 @@ def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: rational_result = solver(_one_hot_perturbation(rational=True)) double_result = solver(_one_hot_perturbation(rational=False)) + rational_equilibria = _equilibria(rational_result) + double_equilibria = _equilibria(double_result) with subtests.test("perturbation converted to double precision"): - assert isinstance( - rational_result.parameters["perturbation"], gbt.MixedStrategyProfileDouble - ) + assert isinstance(rational_result.perturbation, gbt.MixedStrategyProfileDouble) with subtests.test("number of equilibria found"): - assert len(rational_result.equilibria) == len(double_result.equilibria) + assert len(rational_equilibria) == len(double_equilibria) for i, (rational_eq, double_eq) in enumerate( - zip(rational_result.equilibria, double_result.equilibria, strict=True) + zip(rational_equilibria, double_equilibria, strict=True) ): with subtests.test(eq=i, check="strategy_profile"): for player in game.players: @@ -1765,9 +1776,10 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s game = test_case.factory() start = game.mixed_strategy_profile(**test_case.start_data) result = test_case.solver(start) + equilibria = _equilibria(result) with subtests.test("number of equilibria found"): - assert len(result.equilibria) == len(test_case.expected) - for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): + assert len(equilibria) == len(test_case.expected) + for i, (eq, exp) in enumerate(zip(equilibria, test_case.expected, strict=True)): with subtests.test(eq=i, check="max_regret"): assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="strategy_profile"): @@ -3245,9 +3257,10 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: """ game = test_case.factory() result = test_case.solver(game) + equilibria = _equilibria(result) with subtests.test("number of equilibria found"): - assert len(result.equilibria) == len(test_case.expected) - for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): + assert len(equilibria) == len(test_case.expected) + for i, (eq, exp) in enumerate(zip(equilibria, test_case.expected, strict=True)): with subtests.test(eq=i, check="max_regret"): assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="max_regret"): @@ -3317,9 +3330,10 @@ def are_the_same(game, found, candidate): game = test_case.factory() result = test_case.solver(game) + equilibria = _equilibria(result) with subtests.test("number of equilibria found"): - assert len(result.equilibria) == len(test_case.expected) - for i, eq in enumerate(result.equilibria): + assert len(equilibria) == len(test_case.expected) + for i, eq in enumerate(equilibria): with subtests.test(eq=i, check="agent_max_regret"): assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="max_regret"): @@ -3464,9 +3478,10 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: """ game = test_case.factory() result = test_case.solver(game) + equilibria = _equilibria(result) with subtests.test("number of equilibria found"): - assert len(result.equilibria) == len(test_case.expected) - for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): + assert len(equilibria) == len(test_case.expected) + for i, (eq, exp) in enumerate(zip(equilibria, test_case.expected, strict=True)): with subtests.test(eq=i, check="agent_max_regret"): assert eq.agent_max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="max_regret"): @@ -3532,9 +3547,10 @@ def test_nash_agent_w_start_solver(test_case: EquilibriumTestCase, subtests) -> game = test_case.factory() start = game.mixed_behavior_profile(**test_case.start_data) result = test_case.solver(start) + equilibria = _equilibria(result) with subtests.test("number of equilibria found"): - assert len(result.equilibria) == len(test_case.expected) - for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): + assert len(equilibria) == len(test_case.expected) + for i, (eq, exp) in enumerate(zip(equilibria, test_case.expected, strict=True)): with subtests.test(eq=i, check="agent_max_regret"): assert eq.agent_max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="max_regret"):