diff --git a/ChangeLog b/ChangeLog index 138f7bf7a9..cb4b02ea81 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,9 @@ ## [17.0.0-beta.2] - unreleased +### Added +- Details on bifurcations found and actions taken to resolve them (by perturbation) are now reported + by `nash.logit_solve` (and related functions) (#186) + ### Changed - Refined the perturbation approach in `path.cc` (logit and hp solvers) to better handle paths on games with bifurcations. (#492) diff --git a/doc/tools.logit.rst b/doc/tools.logit.rst index 7cb9a142bd..a905c6fb7e 100644 --- a/doc/tools.logit.rst +++ b/doc/tools.logit.rst @@ -74,6 +74,16 @@ See the :ref:`algorithm description ` for full details. this switch is specified, only the approximation to the Nash equilibrium at the end of the branch is output. +.. cmdoption:: -b + + .. versionadded:: 17.0.0 + + Report any bifurcations detected while tracing, as comment lines giving the + bracketing values of lambda. Bifurcations are reported inline, interleaved + with the trace, as soon as each is detected. This switch has no additional + effect unless :option:`-e` is also given, since bifurcations are already + reported as part of the full trace otherwise. + Computing the principal branch, in mixed strategies, of the reduced strategic form of the example in Figure 2 of :cite:p:`Sel75`:: diff --git a/src/gui/dllogit.h b/src/gui/dllogit.h index c946e64198..5c661ef470 100644 --- a/src/gui/dllogit.h +++ b/src/gui/dllogit.h @@ -296,7 +296,11 @@ template class LogitThreadRunner final : public wxThread { Traits::Solve( m_game, [this](const LogitEvent &p_event) { - PostPoint(std::get>(p_event).qre); + if (const auto *pathEvent = + std::get_if>(&p_event)) { + PostPoint(pathEvent->qre); + } + // Bifurcation/perturbation events are not yet visualized in the GUI's logit plot. }, m_cancel); } diff --git a/src/pygambit/callback.h b/src/pygambit/callback.h index bd99dbbaab..b7ec8104c8 100644 --- a/src/pygambit/callback.h +++ b/src/pygambit/callback.h @@ -59,11 +59,23 @@ std::string InvokeBehaviorCallbackRational( PyObject *p_callback, std::shared_ptr> p_profile); std::string -InvokeLogitStrategyEventCallback(PyObject *p_callback, - std::shared_ptr p_qre); +InvokeLogitStrategyPathEventCallback(PyObject *p_callback, + std::shared_ptr p_qre); std::string -InvokeLogitBehaviorEventCallback(PyObject *p_callback, - std::shared_ptr p_qre); +InvokeLogitBehaviorPathEventCallback(PyObject *p_callback, + std::shared_ptr p_qre); +std::string InvokeLogitStrategyBifurcationEventCallback( + PyObject *p_callback, std::shared_ptr p_before, + std::shared_ptr p_after); +std::string InvokeLogitBehaviorBifurcationEventCallback( + PyObject *p_callback, std::shared_ptr p_before, + std::shared_ptr p_after); +std::string InvokeLogitStrategyPerturbationEventCallback( + PyObject *p_callback, bool p_active, + std::shared_ptr p_qre); +std::string InvokeLogitBehaviorPerturbationEventCallback( + PyObject *p_callback, bool p_active, + std::shared_ptr p_qre); std::string InvokeHPStrategyEventCallback(PyObject *p_callback, std::shared_ptr> p_profile, @@ -195,10 +207,11 @@ MakeBehaviorCallback(PyObject *p_callback) } /// -/// Builds a LogitEventCallbackType which, when invoked with a -/// path-tracing event, calls a Python callable with the QRE point traced so -/// far. A null callback (Python `None`) yields the solver's own no-op -/// default. +/// Builds a LogitEventCallbackType which, when invoked, dispatches to whichever +/// Invoke*EventCallback trampoline matches the alternative held by the event (an ordinary +/// traced point, a detected bifurcation, or a perturbation switching on/off), calling a +/// Python callable with the corresponding pygambit event object. A null callback (Python +/// `None`) yields the solver's own no-op default. /// template Gambit::LogitEventCallbackType MakeLogitEventCallback(PyObject *p_callback); @@ -211,10 +224,28 @@ MakeLogitEventCallback(PyObject *p_callbac return Gambit::NullLogitEventCallback; } return [p_callback](const Gambit::LogitEvent &p_event) { - const auto &qre = - std::get>(p_event).qre; - Gambit::ThrowIfPythonError(InvokeLogitStrategyEventCallback( - p_callback, std::make_shared(qre))); + using Gambit::LogitQREMixedStrategyProfile; + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeLogitStrategyPathEventCallback( + p_callback, std::make_shared(event.qre))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeLogitStrategyBifurcationEventCallback( + p_callback, std::make_shared(event.before), + std::make_shared(event.after))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeLogitStrategyPerturbationEventCallback( + p_callback, event.active, + std::make_shared(event.qre))); + } + }, + p_event); }; } @@ -226,10 +257,28 @@ MakeLogitEventCallback(PyObject *p_callbac return Gambit::NullLogitEventCallback; } return [p_callback](const Gambit::LogitEvent &p_event) { - const auto &qre = - std::get>(p_event).qre; - Gambit::ThrowIfPythonError(InvokeLogitBehaviorEventCallback( - p_callback, std::make_shared(qre))); + using Gambit::LogitQREMixedBehaviorProfile; + std::visit( + [p_callback](const Event &event) { + if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeLogitBehaviorPathEventCallback( + p_callback, std::make_shared(event.qre))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeLogitBehaviorBifurcationEventCallback( + p_callback, std::make_shared(event.before), + std::make_shared(event.after))); + } + else if constexpr (std::is_same_v>) { + Gambit::ThrowIfPythonError(InvokeLogitBehaviorPerturbationEventCallback( + p_callback, event.active, + std::make_shared(event.qre))); + } + }, + p_event); }; } diff --git a/src/pygambit/cli/logit.py b/src/pygambit/cli/logit.py index bc4e90a501..37f1bd5c65 100644 --- a/src/pygambit/cli/logit.py +++ b/src/pygambit/cli/logit.py @@ -56,6 +56,20 @@ def _render_qre_row(qre, decimals: int) -> str: return text +def _render_bifurcation_comment(event, decimals: int) -> str: + """Render a `LogitBifurcationEvent` as a comment line reporting the lambda + interval bracketing the detected bifurcation. + """ + lo, hi = event.before.lam, event.after.lam + return f"# bifurcation: lambda in [{lo:.{decimals}f}, {hi:.{decimals}f}]" + + +def _render_perturbation_comment(event, decimals: int) -> str: + """Render a `LogitPerturbationEvent` as a comment line.""" + state = "started" if event.active else "ended" + return f"# perturbation {state} near lambda={event.qre.lam:.{decimals}f}" + + def _read_frequencies(path: str, game: gbt.Game) -> gbt.MixedStrategyProfileDouble: """Read observed strategy frequencies for maximum-likelihood estimation: a flat, comma-separated list of counts, one per strategy, in the same order as a profile's @@ -135,6 +149,16 @@ def _read_frequencies(path: str, game: gbt.Game) -> gbt.MixedStrategyProfileDoub is_flag=True, help="print only the terminal equilibrium (default is to print the entire branch)", ) +@click.option( + "-b", + "--report-bifurcations", + is_flag=True, + help=( + "report any bifurcations detected while tracing, as comment lines; " + "has no additional effect unless --terminal-only is also given, since " + "bifurcations are already reported as part of the traced branch otherwise" + ), +) @click.option("-q", "--quiet", is_flag=True, help="quiet mode (suppresses banner)") @version_option(DESCRIPTION) @handle_errors @@ -148,16 +172,29 @@ def main( target_lambda: tuple[float, ...], mle_file: str | None, terminal_only: bool, + report_bifurcations: bool, quiet: bool, ) -> None: game = load_game(quiet, DESCRIPTION, file, PROG_NAME) if not game.is_perfect_recall: raise ValueError("Computing equilibria of games with imperfect recall is not supported.") - def stream(qre) -> None: - click.echo(_render_qre_row(qre, decimals)) + def stream(event) -> None: + if isinstance(event, gbt.LogitPathEvent): + click.echo(_render_qre_row(event.qre, decimals)) + elif isinstance(event, gbt.LogitBifurcationEvent): + click.echo(_render_bifurcation_comment(event, decimals)) + elif isinstance(event, gbt.LogitPerturbationEvent): + click.echo(_render_perturbation_comment(event, decimals)) - event_callback = None if terminal_only else stream + def stream_bifurcations_only(event) -> None: + if isinstance(event, gbt.LogitBifurcationEvent): + click.echo(_render_bifurcation_comment(event, decimals)) + + if terminal_only: + event_callback = stream_bifurcations_only if report_bifurcations else None + else: + event_callback = stream # Maximum-likelihood estimation, like the C++ tool, is only defined over the # strategic representation, since the observed frequencies are read as a flat @@ -192,6 +229,7 @@ def stream(qre) -> None: maxregret=maxregret, first_step=first_step, max_accel=max_accel, + event_callback=event_callback, ) click.echo(render_profile_csv(result.equilibrium, "NE", decimals, fixed=False)) return @@ -202,7 +240,7 @@ def stream(qre) -> None: maxregret=maxregret, first_step=first_step, max_accel=max_accel, - event_callback=stream, + event_callback=event_callback, ) click.echo(render_profile_csv(result.equilibrium, "NE", decimals, fixed=False)) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 8855dc52da..d69694ccfc 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -729,14 +729,23 @@ cdef extern from "solvers/logit/logit.h": Converged RegretTargetNotReached + cdef cppclass c_LogitBifurcationStrategy "LogitBifurcation": + c_LogitQREMixedStrategyProfile before + c_LogitQREMixedStrategyProfile after + cdef cppclass c_LogitBifurcationBehavior "LogitBifurcation": + c_LogitQREMixedBehaviorProfile before + c_LogitQREMixedBehaviorProfile after + cdef cppclass c_LogitStrategyResult "Gambit::LogitStrategyResult": optional[c_MixedStrategyProfile[double]] equilibrium bool success c_LogitTerminationReason reason + stdvector[c_LogitBifurcationStrategy] bifurcations cdef cppclass c_LogitBehaviorResult "Gambit::LogitBehaviorResult": optional[c_MixedBehaviorProfile[double]] equilibrium bool success c_LogitTerminationReason reason + stdvector[c_LogitBifurcationBehavior] bifurcations c_LogitStrategyResult LogitStrategySolveEquilibrium( c_LogitQREMixedStrategyProfile, double, double, double, diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index bd6ad5c457..d0b3831d2c 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -47,7 +47,8 @@ LogitBehaviorPrincipalBranchWrapper(const Game &p_game, double p_regret, double double p_maxAccel) { return LogitBehaviorSolve(LogitQREMixedBehaviorProfile(p_game), p_regret, - PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel); + PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel) + .profiles; } std::shared_ptr @@ -82,7 +83,8 @@ LogitStrategyPrincipalBranchWrapper(const Game &p_game, double p_regret, double double p_maxAccel) { return LogitStrategySolve(LogitQREMixedStrategyProfile(p_game), p_regret, - PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel); + PathTracer::TraceDirection::Positive, p_firstStep, p_maxAccel) + .profiles; } std::list> diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index dda9a67d3f..4454e5b48e 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -158,6 +158,44 @@ class LogitTerminationReason(enum.Enum): REGRET_TARGET_NOT_REACHED = 1 +@dataclasses.dataclass(frozen=True) +class LogitPathEvent: + """Reports one point traced along the principal branch of the logit QRE + correspondence. + """ + qre: "LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile" + + +@dataclasses.dataclass(frozen=True) +class LogitBifurcationEvent: + """Reports a bifurcation the moment it is detected while tracing the logit QRE + correspondence, bracketed by the last point before, and first point after, the + change in branch orientation. + """ + before: "LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile" + after: "LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile" + + +@dataclasses.dataclass(frozen=True) +class LogitPerturbationEvent: + """Reports the tracer switching a symmetry-breaking perturbation on or off while + attempting to cross a suspected bifurcation. + """ + active: bool + qre: "LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile" + + +@dataclasses.dataclass(frozen=True) +class LogitBifurcation: + """Reports a bifurcation detected while tracing the logit QRE correspondence, + bracketed by the last point before, and first point after, the change in branch + orientation. Unlike `LogitBifurcationEvent`, this is not reported through + `event_callback` but accumulated into `LogitResult.bifurcations`. + """ + before: "LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile" + after: "LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile" + + @dataclasses.dataclass(frozen=True) class EnumPolyCandidateSupportEvent: """Reports a support profile examined by :ref:`enumpoly ` as a candidate @@ -467,6 +505,8 @@ class LogitResult(NashResultBase): Whether `equilibrium` was accepted. reason : LogitTerminationReason Why tracing did not reach an accepted equilibrium, if it did not. + bifurcations : list of LogitBifurcation + Any bifurcations detected while tracing towards `equilibrium`. """ maxregret: float first_step: float @@ -474,6 +514,7 @@ class LogitResult(NashResultBase): equilibrium: MixedStrategyProfileDouble | MixedBehaviorProfileDouble | None success: bool reason: LogitTerminationReason + bifurcations: list[LogitBifurcation] @dataclasses.dataclass(frozen=True, kw_only=True) @@ -541,21 +582,73 @@ cdef public string InvokeBehaviorCallbackRational( return b"" -cdef public string InvokeLogitStrategyEventCallback( +cdef public string InvokeLogitStrategyPathEventCallback( callback, qre: shared_ptr[c_LogitQREMixedStrategyProfile] ): try: - callback(LogitQREMixedStrategyProfile.wrap(qre)) + callback(LogitPathEvent(qre=LogitQREMixedStrategyProfile.wrap(qre))) except BaseException as e: return f"{type(e).__name__}: {e}".encode("utf-8") return b"" -cdef public string InvokeLogitBehaviorEventCallback( +cdef public string InvokeLogitBehaviorPathEventCallback( callback, qre: shared_ptr[c_LogitQREMixedBehaviorProfile] ): try: - callback(LogitQREMixedBehaviorProfile.wrap(qre)) + callback(LogitPathEvent(qre=LogitQREMixedBehaviorProfile.wrap(qre))) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLogitStrategyBifurcationEventCallback( + callback, before: shared_ptr[c_LogitQREMixedStrategyProfile], + after: shared_ptr[c_LogitQREMixedStrategyProfile] +): + try: + callback(LogitBifurcationEvent( + before=LogitQREMixedStrategyProfile.wrap(before), + after=LogitQREMixedStrategyProfile.wrap(after), + )) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLogitBehaviorBifurcationEventCallback( + callback, before: shared_ptr[c_LogitQREMixedBehaviorProfile], + after: shared_ptr[c_LogitQREMixedBehaviorProfile] +): + try: + callback(LogitBifurcationEvent( + before=LogitQREMixedBehaviorProfile.wrap(before), + after=LogitQREMixedBehaviorProfile.wrap(after), + )) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLogitStrategyPerturbationEventCallback( + callback, active: bool, qre: shared_ptr[c_LogitQREMixedStrategyProfile] +): + try: + callback(LogitPerturbationEvent( + active=active, qre=LogitQREMixedStrategyProfile.wrap(qre) + )) + except BaseException as e: + return f"{type(e).__name__}: {e}".encode("utf-8") + return b"" + + +cdef public string InvokeLogitBehaviorPerturbationEventCallback( + callback, active: bool, qre: shared_ptr[c_LogitQREMixedBehaviorProfile] +): + try: + callback(LogitPerturbationEvent( + active=active, qre=LogitQREMixedBehaviorProfile.wrap(qre) + )) except BaseException as e: return f"{type(e).__name__}: {e}".encode("utf-8") return b"" @@ -843,6 +936,43 @@ def _convert_mbp_opt_r( ) +@cython.cfunc +def _convert_logit_bifurcations_strategy( + bifurcations: stdvector[c_LogitBifurcationStrategy] +) -> list: + # Indexed rather than a `for b in bifurcations` loop: Cython would declare a + # by-value loop variable of type c_LogitBifurcationStrategy, which (like QRE + # itself) has no default constructor. + return [ + LogitBifurcation( + before=LogitQREMixedStrategyProfile.wrap( + make_shared[c_LogitQREMixedStrategyProfile](bifurcations[i].before) + ), + after=LogitQREMixedStrategyProfile.wrap( + make_shared[c_LogitQREMixedStrategyProfile](bifurcations[i].after) + ), + ) + for i in range(bifurcations.size()) + ] + + +@cython.cfunc +def _convert_logit_bifurcations_behavior( + bifurcations: stdvector[c_LogitBifurcationBehavior] +) -> list: + return [ + LogitBifurcation( + before=LogitQREMixedBehaviorProfile.wrap( + make_shared[c_LogitQREMixedBehaviorProfile](bifurcations[i].before) + ), + after=LogitQREMixedBehaviorProfile.wrap( + make_shared[c_LogitQREMixedBehaviorProfile](bifurcations[i].after) + ), + ) + for i in range(bifurcations.size()) + ] + + def _enumpure_strategy_solve(game: Game, nash_callback: object = None) -> EnumPureResult: result: c_EnumPureStrategyResult = EnumPureStrategySolve( game.game, MakeStrategyCallback[c_Rational](nash_callback) @@ -1184,6 +1314,7 @@ def _logit_strategy_solve( maxregret=maxregret, first_step=first_step, max_accel=max_accel, equilibrium=_convert_msp_opt_d(result.equilibrium), success=result.success, reason=LogitTerminationReason(result.reason), + bifurcations=_convert_logit_bifurcations_strategy(result.bifurcations), ) @@ -1201,6 +1332,7 @@ def _logit_behavior_solve( maxregret=maxregret, first_step=first_step, max_accel=max_accel, equilibrium=_convert_mbp_opt_d(result.equilibrium), success=result.success, reason=LogitTerminationReason(result.reason), + bifurcations=_convert_logit_bifurcations_behavior(result.bifurcations), ) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 8a4fa58644..f48934cb86 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -925,7 +925,8 @@ def logit_solve( first_step: float = .03, max_accel: float = 1.1, event_callback: Callable[ - [libgbt.LogitQREMixedStrategyProfile | libgbt.LogitQREMixedBehaviorProfile], None + [libgbt.LogitPathEvent | libgbt.LogitBifurcationEvent | libgbt.LogitPerturbationEvent], + None, ] | None = None, ) -> LogitResult: """Compute a Nash equilibrium of a game using :ref:`the logit quantal response @@ -960,17 +961,21 @@ def logit_solve( .. versionadded:: 16.2.0 - event_callback : Callable[[LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile], \ -None], optional - If specified, called with each point traced along the principal - branch on the way to the returned equilibrium. + event_callback : Callable[[LogitPathEvent | LogitBifurcationEvent | \ +LogitPerturbationEvent], None], optional + If specified, called with each point traced along the principal branch on the + way to the returned equilibrium (``LogitPathEvent``), the moment a bifurcation + is detected (``LogitBifurcationEvent``), and each time the tracer switches a + symmetry-breaking perturbation on or off while crossing one + (``LogitPerturbationEvent``). .. versionadded:: 17.0.0 Returns ------- res : LogitResult - The result represented as a ``LogitResult`` object. + The result represented as a ``LogitResult`` object. Any bifurcations detected + while tracing are also available afterward via ``res.bifurcations``. """ if maxregret <= 0.0: raise ValueError("logit_solve(): maxregret argument must be positive") diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index ff8e73202d..8eefca197c 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -58,7 +58,8 @@ def logit_solve_lambda( first_step: float = .03, max_accel: float = 1.1, event_callback: Callable[ - [libgbt.LogitQREMixedStrategyProfile | libgbt.LogitQREMixedBehaviorProfile], None + [libgbt.LogitPathEvent | libgbt.LogitBifurcationEvent | libgbt.LogitPerturbationEvent], + None, ] | None = None, ): """Compute the QRE(s) at the specified value(s) of `lam` along the principal branch. @@ -67,7 +68,10 @@ def logit_solve_lambda( ---------- event_callback : Callable, optional If specified, called with each point traced along the principal branch on the - way to each requested value of `lam`. + way to each requested value of `lam` (``LogitPathEvent``), the moment a + bifurcation is detected (``LogitBifurcationEvent``), and each time the tracer + switches a symmetry-breaking perturbation on or off while crossing one + (``LogitPerturbationEvent``). .. versionadded:: 17.0.0 """ @@ -296,7 +300,8 @@ def logit_estimate( first_step: float = .03, max_accel: float = 1.1, event_callback: Callable[ - [libgbt.LogitQREMixedStrategyProfile | libgbt.LogitQREMixedBehaviorProfile], None + [libgbt.LogitPathEvent | libgbt.LogitBifurcationEvent | libgbt.LogitPerturbationEvent], + None, ] | None = None, ) -> LogitQREMixedStrategyFitResult | LogitQREMixedBehaviorFitResult: """Use maximum likelihood estimation to find the logit quantal @@ -355,10 +360,13 @@ def logit_estimate( This argument only has an effect when use_empirical is False. - event_callback : Callable[[LogitQREMixedStrategyProfile | LogitQREMixedBehaviorProfile], \ -None], optional + event_callback : Callable[[LogitPathEvent | LogitBifurcationEvent | \ +LogitPerturbationEvent], None], optional If specified, called with each point traced along the principal branch on the - way to the best-fitting QRE. + way to the best-fitting QRE (``LogitPathEvent``), the moment a bifurcation is + detected (``LogitBifurcationEvent``), and each time the tracer switches a + symmetry-breaking perturbation on or off while crossing one + (``LogitPerturbationEvent``). .. note:: diff --git a/src/solvers/logit/efglogit.cc b/src/solvers/logit/efglogit.cc index ab51ce40c9..7ca551f5ed 100644 --- a/src/solvers/logit/efglogit.cc +++ b/src/solvers/logit/efglogit.cc @@ -252,21 +252,50 @@ class TracingCallbackFunction { ~TracingCallbackFunction() = default; void AppendPoint(const Vector &p_point); + void OnBifurcation(const Vector &p_before, const Vector &p_after); + void OnPerturbation(bool p_active, const Vector &p_point); const std::list &GetProfiles() const { return m_profiles; } + const std::vector> &GetBifurcations() const + { + return m_bifurcations; + } private: + LogitQREMixedBehaviorProfile PointToQRE(const Vector &p_point) const; + Game m_game; LogitEventCallbackType m_onEvent; std::list m_profiles; + std::vector> m_bifurcations; }; +LogitQREMixedBehaviorProfile +TracingCallbackFunction::PointToQRE(const Vector &p_point) const +{ + return {PointToProfile(m_game, p_point), p_point.back(), 1.0}; +} + void TracingCallbackFunction::AppendPoint(const Vector &p_point) { - const MixedBehaviorProfile profile(PointToProfile(m_game, p_point)); - m_profiles.emplace_back(profile, p_point.back(), 1.0); + m_profiles.push_back(PointToQRE(p_point)); m_onEvent(LogitPathEvent{m_profiles.back()}); } +void TracingCallbackFunction::OnBifurcation(const Vector &p_before, + const Vector &p_after) +{ + m_bifurcations.push_back({PointToQRE(p_before), PointToQRE(p_after)}); + const auto &bifurcation = m_bifurcations.back(); + m_onEvent( + LogitBifurcationEvent{bifurcation.before, bifurcation.after}); +} + +void TracingCallbackFunction::OnPerturbation(bool p_active, const Vector &p_point) +{ + const auto qre = PointToQRE(p_point); + m_onEvent(LogitPerturbationEvent{p_active, qre}); +} + class EstimatorCallbackFunction { public: EstimatorCallbackFunction(const Game &p_game, const Vector &p_frequencies, @@ -274,9 +303,13 @@ class EstimatorCallbackFunction { ~EstimatorCallbackFunction() = default; void EvaluatePoint(const Vector &p_point); + void OnBifurcation(const Vector &p_before, const Vector &p_after); + void OnPerturbation(bool p_active, const Vector &p_point); const LogitQREMixedBehaviorProfile &GetMaximizer() const { return m_bestProfile; } private: + LogitQREMixedBehaviorProfile PointToQRE(const Vector &p_point) const; + Game m_game; const Vector &m_frequencies; LogitEventCallbackType m_onEvent; @@ -293,23 +326,42 @@ EstimatorCallbackFunction::EstimatorCallbackFunction( { } -void EstimatorCallbackFunction::EvaluatePoint(const Vector &p_point) +LogitQREMixedBehaviorProfile +EstimatorCallbackFunction::PointToQRE(const Vector &p_point) const { const MixedBehaviorProfile profile(PointToProfile(m_game, p_point)); - auto qre = LogitQREMixedBehaviorProfile( - profile, p_point.back(), - LogLike(m_frequencies, static_cast &>(profile))); + return {profile, p_point.back(), + LogLike(m_frequencies, static_cast &>(profile))}; +} + +void EstimatorCallbackFunction::EvaluatePoint(const Vector &p_point) +{ + auto qre = PointToQRE(p_point); m_onEvent(LogitPathEvent{qre}); if (qre.GetLogLike() > m_bestProfile.GetLogLike()) { m_bestProfile = qre; } } +void EstimatorCallbackFunction::OnBifurcation(const Vector &p_before, + const Vector &p_after) +{ + const auto before = PointToQRE(p_before); + const auto after = PointToQRE(p_after); + m_onEvent(LogitBifurcationEvent{before, after}); +} + +void EstimatorCallbackFunction::OnPerturbation(bool p_active, const Vector &p_point) +{ + const auto qre = PointToQRE(p_point); + m_onEvent(LogitPerturbationEvent{p_active, qre}); +} + } // namespace namespace Gambit { -std::list +LogitTrace LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, Nash::BehaviorCallbackType p_onEquilibrium, @@ -317,7 +369,7 @@ LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, const CancelToken &p_cancel) { if (p_start.size() == 0) { - return {p_start}; + return {{p_start}, {}}; } PathTracer tracer; tracer.SetMaxDecel(p_maxAccel); @@ -343,12 +395,18 @@ LogitBehaviorSolve(const LogitQREMixedBehaviorProfile &p_start, double p_regret, return RegretTerminationFunction(game, p_point, p_regret); }, [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, - NullCriterionFunction, NullCriterionBracketFunction, p_cancel); + NullCriterionFunction, NullCriterionBracketFunction, p_cancel, + [&callback](const Vector &p_before, const Vector &p_after) { + callback.OnBifurcation(p_before, p_after); + }, + [&callback](bool p_active, const Vector &p_point) { + callback.OnPerturbation(p_active, p_point); + }); const auto &profiles = callback.GetProfiles(); if (profiles.back().GetProfile().GetAgentMaxRegret() < p_regret) { p_onEquilibrium(profiles.back().GetProfile()); } - return profiles; + return {profiles, callback.GetBifurcations()}; } std::list LogitBehaviorSolveLambda( @@ -380,6 +438,13 @@ std::list LogitBehaviorSolveLambda( [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, [lam](const Vector &x, const Vector &) -> double { return x.back() - lam; + }, + NullCriterionBracketFunction, CancelToken(), + [&callback](const Vector &p_before, const Vector &p_after) { + callback.OnBifurcation(p_before, p_after); + }, + [&callback](bool p_active, const Vector &p_point) { + callback.OnPerturbation(p_active, p_point); }); ret.push_back(callback.GetProfiles().back()); } @@ -423,6 +488,13 @@ LogitBehaviorEstimate(const MixedBehaviorProfile &p_frequencies, double }, [&restart](const Vector &, const Vector &p_restart) -> void { restart = p_restart; + }, + CancelToken(), + [&callback](const Vector &p_before, const Vector &p_after) { + callback.OnBifurcation(p_before, p_after); + }, + [&callback](bool p_active, const Vector &p_point) { + callback.OnPerturbation(p_active, p_point); }); if (p_stopAtLocal || x.back() >= p_maxLambda) { break; diff --git a/src/solvers/logit/logit.h b/src/solvers/logit/logit.h index 9fb24ecd94..944de898c3 100644 --- a/src/solvers/logit/logit.h +++ b/src/solvers/logit/logit.h @@ -24,8 +24,10 @@ #define GAMBIT_SOLVERS_LOGIT_LOGIT_H #include +#include #include #include +#include #include "solvers/nash.h" #include "solvers/path/path.h" @@ -88,11 +90,41 @@ template struct LogitPathEvent { const QRE &qre; }; -template using LogitEvent = std::variant>; +/// @brief The last-accepted point and the rejected point bracketing a detected bifurcation, +/// held by value since a LogitBifurcation may outlive the trace that produced it +template struct LogitBifurcation { + QRE before; + QRE after; +}; + +/// @brief Reports a bifurcation the moment it is detected, bracketed by the points in +/// \p LogitBifurcation +template struct LogitBifurcationEvent { + const QRE &before; + const QRE &after; +}; + +/// @brief Reports the tracer switching its symmetry-breaking perturbation on or off while +/// attempting to cross a suspected bifurcation +template struct LogitPerturbationEvent { + bool active; + const QRE &qre; +}; + +template +using LogitEvent = + std::variant, LogitBifurcationEvent, LogitPerturbationEvent>; template using LogitEventCallbackType = std::function &)>; template void NullLogitEventCallback(const LogitEvent &) {} +/// @brief The points traced along a branch of the logit QRE correspondence, together with any +/// bifurcations detected along the way +template struct LogitTrace { + std::list profiles; + std::vector> bifurcations; +}; + /// @brief Why a call to LogitStrategySolveEquilibrium()/LogitBehaviorSolveEquilibrium() did /// not return an accepted equilibrium enum class LogitTerminationReason { @@ -102,7 +134,7 @@ enum class LogitTerminationReason { // out of path }; -std::list LogitStrategySolve( +LogitTrace LogitStrategySolve( const LogitQREMixedStrategyProfile &p_start, double p_regret, PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, Nash::StrategyCallbackType p_onEquilibrium = Nash::NullStrategyCallback, @@ -116,6 +148,7 @@ struct LogitStrategyResult { std::optional> equilibrium; bool success{false}; LogitTerminationReason reason{LogitTerminationReason::RegretTargetNotReached}; + std::vector> bifurcations; }; /// @brief Trace the principal branch of the logit QRE correspondence for a strategic game, @@ -128,16 +161,16 @@ inline LogitStrategyResult LogitStrategySolveEquilibrium( 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 auto trace = LogitStrategySolve(p_start, p_regret, p_direction, p_firstStep, p_maxAccel, + Nash::NullStrategyCallback, p_onEvent, p_cancel); + const MixedStrategyProfile &candidate = trace.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 {std::nullopt, false, LogitTerminationReason::RegretTargetNotReached, + trace.bifurcations}; } - return {candidate, true, LogitTerminationReason::Converged}; + return {candidate, true, LogitTerminationReason::Converged, trace.bifurcations}; } std::list LogitStrategySolveLambda( @@ -155,7 +188,7 @@ LogitStrategyEstimate(const MixedStrategyProfile &p_frequencies, double using LogitQREMixedBehaviorProfile = LogitQRE>; -std::list LogitBehaviorSolve( +LogitTrace LogitBehaviorSolve( const LogitQREMixedBehaviorProfile &p_start, double p_regret, PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, Nash::BehaviorCallbackType p_onEquilibrium = Nash::NullBehaviorCallback, @@ -169,6 +202,7 @@ struct LogitBehaviorResult { std::optional> equilibrium; bool success{false}; LogitTerminationReason reason{LogitTerminationReason::RegretTargetNotReached}; + std::vector> bifurcations; }; /// @brief Trace the principal branch of the logit QRE correspondence for an extensive game, @@ -181,16 +215,16 @@ inline LogitBehaviorResult LogitBehaviorSolveEquilibrium( 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 auto trace = LogitBehaviorSolve(p_start, p_regret, p_direction, p_firstStep, p_maxAccel, + Nash::NullBehaviorCallback, p_onEvent, p_cancel); + const MixedBehaviorProfile &candidate = trace.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 {std::nullopt, false, LogitTerminationReason::RegretTargetNotReached, + trace.bifurcations}; } - return {candidate, true, LogitTerminationReason::Converged}; + return {candidate, true, LogitTerminationReason::Converged, trace.bifurcations}; } std::list LogitBehaviorSolveLambda( diff --git a/src/solvers/logit/nfglogit.cc b/src/solvers/logit/nfglogit.cc index 7703c2a3fc..2ea083c888 100644 --- a/src/solvers/logit/nfglogit.cc +++ b/src/solvers/logit/nfglogit.cc @@ -291,22 +291,52 @@ class TracingCallbackFunction { ~TracingCallbackFunction() = default; void AppendPoint(const Vector &p_point); + void OnBifurcation(const Vector &p_before, const Vector &p_after); + void OnPerturbation(bool p_active, const Vector &p_point); const std::list &GetProfiles() const { return m_profiles; } + const std::vector> &GetBifurcations() const + { + return m_bifurcations; + } private: + LogitQREMixedStrategyProfile PointToQRE(const Vector &p_point) const; + Game m_game; LogitEventCallbackType m_onEvent; std::list m_profiles; + std::vector> m_bifurcations; }; -void TracingCallbackFunction::AppendPoint(const Vector &p_point) +LogitQREMixedStrategyProfile +TracingCallbackFunction::PointToQRE(const Vector &p_point) const { MixedStrategyProfile profile(m_game->NewMixedStrategyProfile(0.0)); PointToProfile(profile, p_point); - m_profiles.emplace_back(profile, p_point.back(), 1.0); + return {profile, p_point.back(), 1.0}; +} + +void TracingCallbackFunction::AppendPoint(const Vector &p_point) +{ + m_profiles.push_back(PointToQRE(p_point)); m_onEvent(LogitPathEvent{m_profiles.back()}); } +void TracingCallbackFunction::OnBifurcation(const Vector &p_before, + const Vector &p_after) +{ + m_bifurcations.push_back({PointToQRE(p_before), PointToQRE(p_after)}); + const auto &bifurcation = m_bifurcations.back(); + m_onEvent( + LogitBifurcationEvent{bifurcation.before, bifurcation.after}); +} + +void TracingCallbackFunction::OnPerturbation(bool p_active, const Vector &p_point) +{ + const auto qre = PointToQRE(p_point); + m_onEvent(LogitPerturbationEvent{p_active, qre}); +} + class EstimatorCallbackFunction { public: EstimatorCallbackFunction(const Game &p_game, const Vector &p_frequencies, @@ -314,10 +344,14 @@ class EstimatorCallbackFunction { ~EstimatorCallbackFunction() = default; void EvaluatePoint(const Vector &p_point); + void OnBifurcation(const Vector &p_before, const Vector &p_after); + void OnPerturbation(bool p_active, const Vector &p_point); const LogitQREMixedStrategyProfile &GetMaximizer() const { return m_bestProfile; } private: + LogitQREMixedStrategyProfile PointToQRE(const Vector &p_point) const; + Game m_game; const Vector &m_frequencies; LogitEventCallbackType m_onEvent; @@ -333,21 +367,40 @@ EstimatorCallbackFunction::EstimatorCallbackFunction( { } -void EstimatorCallbackFunction::EvaluatePoint(const Vector &p_point) +LogitQREMixedStrategyProfile +EstimatorCallbackFunction::PointToQRE(const Vector &p_point) const { MixedStrategyProfile profile(m_game->NewMixedStrategyProfile(0.0)); PointToProfile(profile, p_point); - auto qre = LogitQREMixedStrategyProfile(profile, p_point.back(), - LogLike(m_frequencies, profile.GetProbVector())); + return {profile, p_point.back(), LogLike(m_frequencies, profile.GetProbVector())}; +} + +void EstimatorCallbackFunction::EvaluatePoint(const Vector &p_point) +{ + auto qre = PointToQRE(p_point); m_onEvent(LogitPathEvent{qre}); if (qre.GetLogLike() > m_bestProfile.GetLogLike()) { m_bestProfile = qre; } } +void EstimatorCallbackFunction::OnBifurcation(const Vector &p_before, + const Vector &p_after) +{ + const auto before = PointToQRE(p_before); + const auto after = PointToQRE(p_after); + m_onEvent(LogitBifurcationEvent{before, after}); +} + +void EstimatorCallbackFunction::OnPerturbation(bool p_active, const Vector &p_point) +{ + const auto qre = PointToQRE(p_point); + m_onEvent(LogitPerturbationEvent{p_active, qre}); +} + } // namespace -std::list +LogitTrace LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, PathTracer::TraceDirection p_direction, double p_firstStep, double p_maxAccel, Nash::StrategyCallbackType p_onEquilibrium, @@ -355,7 +408,7 @@ LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, const CancelToken &p_cancel) { if (p_start.size() == 0) { - return {p_start}; + return {{p_start}, {}}; } PathTracer tracer; tracer.SetMaxDecel(p_maxAccel); @@ -382,12 +435,18 @@ LogitStrategySolve(const LogitQREMixedStrategyProfile &p_start, double p_regret, return RegretTerminationFunction(p_start.GetGame(), p_point, p_regret); }, [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, - NullCriterionFunction, NullCriterionBracketFunction, p_cancel); + NullCriterionFunction, NullCriterionBracketFunction, p_cancel, + [&callback](const Vector &p_before, const Vector &p_after) { + callback.OnBifurcation(p_before, p_after); + }, + [&callback](bool p_active, const Vector &p_point) { + callback.OnPerturbation(p_active, p_point); + }); const auto &profiles = callback.GetProfiles(); if (profiles.back().GetProfile().GetMaxRegret() < p_regret) { p_onEquilibrium(profiles.back().GetProfile()); } - return profiles; + return {profiles, callback.GetBifurcations()}; } std::list LogitStrategySolveLambda( @@ -419,6 +478,13 @@ std::list LogitStrategySolveLambda( [&callback](const Vector &p_point) -> void { callback.AppendPoint(p_point); }, [lam](const Vector &x, const Vector &) -> double { return x.back() - lam; + }, + NullCriterionBracketFunction, CancelToken(), + [&callback](const Vector &p_before, const Vector &p_after) { + callback.OnBifurcation(p_before, p_after); + }, + [&callback](bool p_active, const Vector &p_point) { + callback.OnPerturbation(p_active, p_point); }); ret.push_back(callback.GetProfiles().back()); } @@ -462,6 +528,13 @@ LogitStrategyEstimate(const MixedStrategyProfile &p_frequencies, double }, [&restart](const Vector &, const Vector &p_restart) -> void { restart = p_restart; + }, + CancelToken(), + [&callback](const Vector &p_before, const Vector &p_after) { + callback.OnBifurcation(p_before, p_after); + }, + [&callback](bool p_active, const Vector &p_point) { + callback.OnPerturbation(p_active, p_point); }); if (p_stopAtLocal || x.back() >= p_maxLambda) { break; diff --git a/src/solvers/path/path.cc b/src/solvers/path/path.cc index 33994fbdd3..0b5e18b6a4 100644 --- a/src/solvers/path/path.cc +++ b/src/solvers/path/path.cc @@ -125,12 +125,15 @@ void NewtonStep(Matrix &q, Matrix &b, Vector &u, Vector< // bifurcation point that the tracing gets stuck there as it is not possible // to find a small enough step size to avoid stepping over the bifurcation // point. -TracePathResult PathTracer::TracePath( - std::function &, Vector &)> p_function, - std::function &, Matrix &)> p_jacobian, Vector &x, - TraceDirection p_direction, size_t p_trackingIndex, TerminationFunctionType p_terminate, - CallbackFunctionType p_callback, CriterionFunctionType p_criterion, - CriterionBracketFunctionType p_criterionBracket, const CancelToken &p_cancel) const +TracePathResult +PathTracer::TracePath(std::function &, Vector &)> p_function, + std::function &, Matrix &)> p_jacobian, + Vector &x, TraceDirection p_direction, size_t p_trackingIndex, + TerminationFunctionType p_terminate, CallbackFunctionType p_callback, + CriterionFunctionType p_criterion, + CriterionBracketFunctionType p_criterionBracket, const CancelToken &p_cancel, + BifurcationBracketFunctionType p_onBifurcation, + PerturbationEventFunctionType p_onPerturbation) const { const double c_tol = 1.0e-4; // tolerance for corrector iteration const double c_maxDist = 0.4; // maximal distance to curve @@ -273,8 +276,10 @@ TracePathResult PathTracer::TracePath( // Switch on perturbation and attempt to continue following the branch that // is oriented in the same direction as we were originally following if (pert_countdown == 0.0) { + p_onBifurcation(x, u); pert = c_pert; pert_countdown = std::max(std::abs(10.0 * h), min_pert_countdown); + p_onPerturbation(true, x); } accept = false; } @@ -323,6 +328,7 @@ TracePathResult PathTracer::TracePath( if (pert_countdown < 0.0) { pert = 0.0; pert_countdown = 0.0; + p_onPerturbation(false, x); } } } diff --git a/src/solvers/path/path.h b/src/solvers/path/path.h index 43cf9aff12..3fab1b4a17 100644 --- a/src/solvers/path/path.h +++ b/src/solvers/path/path.h @@ -59,6 +59,19 @@ using CallbackFunctionType = std::function &)>; inline void NullCallbackFunction(const Vector &) {} +// Called once per detected bifurcation, with the last-accepted point and the +// rejected point where the change in tangent orientation was observed. +using BifurcationBracketFunctionType = + std::function &, const Vector &)>; + +inline void NullBifurcationBracketFunction(const Vector &, const Vector &) {} + +// Called when the symmetry-breaking perturbation used to traverse a suspected +// bifurcation is switched on (true) or off (false), with the current point. +using PerturbationEventFunctionType = std::function &)>; + +inline void NullPerturbationEventFunction(bool, const Vector &) {} + struct TracePathResult { Vector final_point; bool status; // true if path tracing terminated successfully, false if it terminated due to error @@ -97,7 +110,9 @@ class PathTracer { CallbackFunctionType p_callback = NullCallbackFunction, CriterionFunctionType p_criterion = NullCriterionFunction, CriterionBracketFunctionType p_criterionBracker = NullCriterionBracketFunction, - const CancelToken &p_cancel = CancelToken()) const; + const CancelToken &p_cancel = CancelToken(), + BifurcationBracketFunctionType p_onBifurcation = NullBifurcationBracketFunction, + PerturbationEventFunctionType p_onPerturbation = NullPerturbationEventFunction) const; private: double m_maxDecel{1.1}, m_hStart{0.03}; diff --git a/tests/cli/test_logit.py b/tests/cli/test_logit.py index d2831c0581..903a5247a6 100644 --- a/tests/cli/test_logit.py +++ b/tests/cli/test_logit.py @@ -110,6 +110,44 @@ def test_maximum_likelihood_estimate_requires_readable_frequency_file( assert result.stderr == f"Error: Error reading strategy frequencies from '{missing}'.\n" +def test_bifurcation_and_perturbation_are_reported_inline(cli_runner, nfg_coordination_text): + """The 2x2 symmetric coordination game's logit QRE correspondence has a + well-known bifurcation; tracing far enough past it (via -l) should report it, + and the perturbation used to cross it, interleaved with the ordinary trace as + soon as each is detected -- not only in a summary at the end. + """ + result = cli_runner.invoke(logit.main, ["-q", "-l", "20"], input=nfg_coordination_text) + assert result.exit_code == 0 + lines = result.stdout.strip().splitlines() + bifurcation_lines = [i for i, line in enumerate(lines) if line.startswith("# bifurcation:")] + started_lines = [i for i, line in enumerate(lines) if "perturbation started" in line] + ended_lines = [i for i, line in enumerate(lines) if "perturbation ended" in line] + assert len(bifurcation_lines) == 1 + assert len(started_lines) == 1 + assert len(ended_lines) == 1 + # Detected mid-trace, not just tacked on to the end of the output. + assert bifurcation_lines[0] < len(lines) - 1 + assert started_lines[0] < ended_lines[0] + + +def test_report_bifurcations_flag_works_under_terminal_only(cli_runner, nfg_coordination_text): + without_flag = cli_runner.invoke( + logit.main, ["-q", "-e", "-l", "20"], input=nfg_coordination_text + ) + with_flag = cli_runner.invoke( + logit.main, ["-q", "-e", "-b", "-l", "20"], input=nfg_coordination_text + ) + assert without_flag.exit_code == 0 + assert with_flag.exit_code == 0 + assert not any( + line.startswith("# bifurcation:") for line in without_flag.stdout.splitlines() + ) + with_flag_lines = with_flag.stdout.strip().splitlines() + assert len(with_flag_lines) == 2 + assert with_flag_lines[0].startswith("# bifurcation:") + assert with_flag_lines[1].startswith("20.000000,") + + def test_imperfect_recall_is_rejected(cli_runner): imperfect_recall_efg = pathlib.Path("tests/test_games/gilboa_two_am_agents.efg").read_text() result = cli_runner.invoke(logit.main, ["-q"], input=imperfect_recall_efg) diff --git a/tests/games.py b/tests/games.py index 0e0a9fba6a..ed79916021 100644 --- a/tests/games.py +++ b/tests/games.py @@ -265,6 +265,23 @@ def create_2x2_zero_sum_efg(variant: None | str = None) -> gbt.Game: return g +def create_2x2_symmetric_coordination_nfg() -> gbt.Game: + """A 2x2 symmetric coordination table game. Its logit QRE correspondence has a + well-known bifurcation partway along the principal branch, useful for + exercising bifurcation/perturbation detection and reporting in the logit + solvers. + """ + game = gbt.Game.new_table([2, 2]) + p1, p2 = game.players + s1a, s1b = game.get_strategies(p1) + s2a, s2b = game.get_strategies(p2) + game.make_outcome({p1: s1a, p2: s2a}, {p1: 1, p2: 1}, "aA") + game.make_outcome({p1: s1a, p2: s2b}, {p1: 0, p2: 0}, "aB") + game.make_outcome({p1: s1b, p2: s2a}, {p1: 0, p2: 0}, "bA") + game.make_outcome({p1: s1b, p2: s2b}, {p1: 1, p2: 1}, "bB") + return game + + def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: """ Returns diff --git a/tests/test_nash.py b/tests/test_nash.py index b8a250cad0..3a1b54cecc 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -3745,6 +3745,27 @@ def test_logit_solve_branch_and_lambda_on_extensive_game(): assert len(events) > 0 +def test_logit_solve_lambda_reports_bifurcation_and_perturbation_events(): + """The 2x2 symmetric coordination game's logit QRE correspondence has a + well-known bifurcation; tracing far enough past it should report it via + `event_callback` as a `LogitBifurcationEvent`, bracketed by matching + `LogitPerturbationEvent(active=True)`/`(active=False)` events around it, + interleaved with the ordinary `LogitPathEvent`s traced along the way.""" + game = games.create_2x2_symmetric_coordination_nfg() + events = [] + gbt.qre.logit_solve_lambda(game, lam=20.0, event_callback=events.append) + + bifurcations = [e for e in events if isinstance(e, gbt.LogitBifurcationEvent)] + perturbations = [e for e in events if isinstance(e, gbt.LogitPerturbationEvent)] + assert any(isinstance(e, gbt.LogitPathEvent) for e in events) + assert len(bifurcations) == 1 + assert [p.active for p in perturbations] == [True, False] + assert bifurcations[0].before.lam < bifurcations[0].after.lam + assert perturbations[0].qre.lam == pytest.approx(bifurcations[0].before.lam) + # Detected in the order it happened, not just collected unordered. + assert events.index(bifurcations[0]) < events.index(perturbations[1]) + + def test_lp_solve_reports_use_strategic_for_native_strategic_game(): """A game that is natively strategic (`is_tree` is False) is always solved on the strategic representation, regardless of the `use_strategic` argument -- the