diff --git a/src/pygambit/cli/enummixed.py b/src/pygambit/cli/enummixed.py index 989d6b6e90..74c425e2e6 100644 --- a/src/pygambit/cli/enummixed.py +++ b/src/pygambit/cli/enummixed.py @@ -69,12 +69,15 @@ def render(profile) -> None: game, rational=rational, nash_callback=render, - cliques=cliques, + components=cliques, ) if cliques: - for index, clique in enumerate(result.cliques, start=1): + polytopes = [ + polytope for component in result.components for polytope in component.polytopes + ] + for index, polytope in enumerate(polytopes, start=1): label = f"convex-{index}" - for profile in clique: + for profile in polytope.vertices: click.echo(render_profile_csv(profile, label, decimals or 0)) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 8855dc52da..06e89471cd 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -750,8 +750,9 @@ cdef extern from "solvers/logit/logit.h": cdef extern from "nash.h": pair[ - c_EnumMixedStrategyResult[T], stdlist[stdlist[c_MixedStrategyProfile[T]]] - ] EnumMixedStrategySolveCliquesWrapper[T]( + c_EnumMixedStrategyResult[T], + stdlist[stdlist[stdlist[c_MixedStrategyProfile[T]]]] + ] EnumMixedStrategySolveComponentsWrapper[T]( c_Game, StrategyCallbackType[T] ) except +RuntimeError stdlist[c_LogitQREMixedBehaviorProfile] LogitBehaviorPrincipalBranchWrapper( diff --git a/src/pygambit/nash.h b/src/pygambit/nash.h index bd6ad5c457..0960b1f6ff 100644 --- a/src/pygambit/nash.h +++ b/src/pygambit/nash.h @@ -28,18 +28,23 @@ using namespace std; using namespace Gambit; template -std::pair, std::list>>> -EnumMixedStrategySolveCliquesWrapper( +std::pair, + std::list>>>> +EnumMixedStrategySolveComponentsWrapper( const Game &p_game, Nash::StrategyCallbackType p_onEquilibrium = Nash::NullStrategyCallback) { auto solution = Nash::EnumMixedStrategySolveDetailed(p_game, p_onEquilibrium); - std::list>> cliques; - for (auto &clique : solution->GetCliques()) { - cliques.emplace_back(clique.begin(), clique.end()); + std::list>>> components; + for (auto &component : solution->GetComponents()) { + std::list>> polytopes; + for (auto &polytope : component) { + polytopes.emplace_back(polytope.begin(), polytope.end()); + } + components.emplace_back(std::move(polytopes)); } return {Nash::EnumMixedStrategyResult{solution->GetExtremeEquilibria(), solution->success}, - cliques}; + components}; } inline std::list diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index dda9a67d3f..d9076380ae 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -186,6 +186,43 @@ MixedStrategyEquilibriumSet = list[MixedStrategyProfile] MixedBehaviorEquilibriumSet = list[MixedBehaviorProfile] +@dataclasses.dataclass(frozen=True) +class NashPolytope: + """A maximal convex subset of a two-player game's set of Nash equilibria. + + Attributes + ---------- + vertices : MixedStrategyEquilibriumSet + The extreme equilibria which are the vertices of this polytope. + """ + vertices: MixedStrategyEquilibriumSet + + +@dataclasses.dataclass(frozen=True) +class NashComponent: + """A connected component of a two-player game's set of Nash equilibria. + + A component consists of one or more `NashPolytope` which touch one + another at a shared vertex; the component itself need not be convex. + + Attributes + ---------- + polytopes : list of NashPolytope + The maximal convex subsets making up this component. + """ + polytopes: list[NashPolytope] + + @property + def vertices(self) -> MixedStrategyEquilibriumSet: + """The union of vertices across this component's polytopes.""" + result: MixedStrategyEquilibriumSet = [] + for polytope in self.polytopes: + for vertex in polytope.vertices: + if vertex not in result: + result.append(vertex) + return result + + @dataclasses.dataclass(frozen=True, kw_only=True) class NashResultBase: """Common attributes shared by every result of a method which computes Nash @@ -238,14 +275,14 @@ class EnumMixedResult(NashResultBase): 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. + components : list of NashComponent, optional + Set if `components=True` was requested: the connected components of the + set of extreme equilibria. """ equilibria: MixedStrategyEquilibriumSet success: bool lrsnash_path: pathlib.Path | str | None = None - cliques: list[MixedStrategyEquilibriumSet] | None = None + components: list[NashComponent] | None = None @dataclasses.dataclass(frozen=True, kw_only=True) @@ -885,36 +922,47 @@ def _enummixed_strategy_solve_rational( ) -def _enummixed_strategy_solve_cliques_double( +def _enummixed_strategy_solve_components_double( game: Game, nash_callback: object = None ) -> EnumMixedResult: result: pair[ - c_EnumMixedStrategyResult[float], stdlist[stdlist[c_MixedStrategyProfile[float]]] - ] = EnumMixedStrategySolveCliquesWrapper[double]( + c_EnumMixedStrategyResult[float], + stdlist[stdlist[stdlist[c_MixedStrategyProfile[float]]]] + ] = EnumMixedStrategySolveComponentsWrapper[double]( game.game, MakeStrategyCallback[double](nash_callback) ) 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], + components=[ + NashComponent(polytopes=[ + NashPolytope(vertices=_convert_mspd(polytope)) for polytope in component + ]) + for component in result.second + ], ) -def _enummixed_strategy_solve_cliques_rational( +def _enummixed_strategy_solve_components_rational( game: Game, nash_callback: object = None ) -> EnumMixedResult: result: pair[ c_EnumMixedStrategyResult[c_Rational], - stdlist[stdlist[c_MixedStrategyProfile[c_Rational]]] - ] = EnumMixedStrategySolveCliquesWrapper[c_Rational]( + stdlist[stdlist[stdlist[c_MixedStrategyProfile[c_Rational]]]] + ] = EnumMixedStrategySolveComponentsWrapper[c_Rational]( game.game, MakeStrategyCallback[c_Rational](nash_callback) ) 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], + components=[ + NashComponent(polytopes=[ + NashPolytope(vertices=_convert_mspr(polytope)) for polytope in component + ]) + for component in result.second + ], ) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 8a4fa58644..66569f0c71 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -51,6 +51,8 @@ LpResult, MixedBehaviorEquilibriumSet, MixedStrategyEquilibriumSet, + NashComponent, + NashPolytope, NashResultBase, SimpdivResult, ) @@ -178,7 +180,7 @@ def enummixed_solve( nash_callback: Callable[ [libgbt.MixedStrategyProfile], None ] | None = None, - cliques: bool = False, + components: bool = False, ) -> EnumMixedResult: """Compute all :ref:`mixed-strategy Nash equilibria ` of a two-player game using the strategic representation. @@ -205,10 +207,10 @@ def enummixed_solve( .. versionadded:: 17.0.0 - cliques : bool, default False - If specified and True, also compute the sets of extreme equilibria which - are connected to one another, returned as `res.cliques`. Not available - when `lrsnash_path` is specified. + components : bool, default False + If specified and True, also compute the connected components of the set of + extreme equilibria, returned as `res.components`. Not available when + `lrsnash_path` is specified. .. versionadded:: 17.0.0 @@ -224,7 +226,7 @@ def enummixed_solve( ValueError If both `lrsnash_path` and `nash_callback` are specified, or both - `lrsnash_path` and `cliques` are specified. + `lrsnash_path` and `components` are specified. Notes ----- @@ -235,9 +237,9 @@ def enummixed_solve( raise ValueError( "enummixed_solve(): nash_callback cannot be used with lrsnash_path" ) - if cliques: + if components: raise ValueError( - "enummixed_solve(): cliques cannot be used with lrsnash_path" + "enummixed_solve(): components cannot be used with lrsnash_path" ) equilibria = nashlrs.lrsnash_solve(game, lrsnash_path=lrsnash_path) return EnumMixedResult( @@ -248,10 +250,10 @@ def enummixed_solve( equilibria=equilibria, success=True, ) - if cliques: + if components: if rational: - return libgbt._enummixed_strategy_solve_cliques_rational(game, nash_callback) - return libgbt._enummixed_strategy_solve_cliques_double(game, nash_callback) + return libgbt._enummixed_strategy_solve_components_rational(game, nash_callback) + return libgbt._enummixed_strategy_solve_components_double(game, nash_callback) if rational: return libgbt._enummixed_strategy_solve_rational(game, nash_callback) return libgbt._enummixed_strategy_solve_double(game, nash_callback) diff --git a/src/solvers/enummixed/enummixed.cc b/src/solvers/enummixed/enummixed.cc index 0f5b2671a8..4b196e7f40 100644 --- a/src/solvers/enummixed/enummixed.cc +++ b/src/solvers/enummixed/enummixed.cc @@ -20,6 +20,10 @@ // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. // +#include +#include +#include + #include "games.h" #include "solvers/linalg/vertenum.h" #include "solvers/enummixed/enummixed.h" @@ -37,8 +41,36 @@ bool EqZero(const double &x) bool EqZero(const Rational &x) { return x == Rational(0); } -template -Array>> EnumMixedStrategySolution::GetCliques() const +namespace { + +int FindRoot(std::vector &p_parent, int p_node) +{ + while (p_parent[p_node] != p_node) { + p_parent[p_node] = p_parent[p_parent[p_node]]; + p_node = p_parent[p_node]; + } + return p_node; +} + +void UnionNodes(std::vector &p_parent, int p_node1, int p_node2) +{ + const int root1 = FindRoot(p_parent, p_node1); + const int root2 = FindRoot(p_parent, p_node2); + if (root1 != root2) { + p_parent[root1] = root2; + } +} + +bool SharesElement(const Array &p_a, const Array &p_b) +{ + return std::any_of(p_a.begin(), p_a.end(), [&](const int p_element) { + return std::find(p_b.begin(), p_b.end(), p_element) != p_b.end(); + }); +} + +} // end anonymous namespace + +template void EnumMixedStrategySolution::EnsureCliques() const { if (m_cliques1.empty()) { // Cliques are generated on demand @@ -56,25 +88,61 @@ Array>> EnumMixedStrategySolution::GetCliques() m_cliques1 = clique.GetCliques1(); m_cliques2 = clique.GetCliques2(); } +} - Array>> solution; - for (size_t cl = 1; cl <= m_cliques1.size(); cl++) { - solution.push_back(Array>()); - for (size_t i = 1; i <= m_cliques1[cl].size(); i++) { - for (size_t j = 1; j <= m_cliques2[cl].size(); j++) { - MixedStrategyProfile profile(m_game->NewMixedStrategyProfile(static_cast(0))); +template +Array> +EnumMixedStrategySolution::BuildCliqueProfiles(size_t p_clique) const +{ + Array> profiles; + for (size_t i = 1; i <= m_cliques1[p_clique].size(); i++) { + for (size_t j = 1; j <= m_cliques2[p_clique].size(); j++) { + MixedStrategyProfile profile(m_game->NewMixedStrategyProfile(static_cast(0))); - for (size_t k = 1; k <= m_key1[m_cliques1[cl][i]].size(); k++) { - profile[k] = m_key1[m_cliques1[cl][i]][k]; - } - for (size_t k = 1; k <= m_key2[m_cliques2[cl][j]].size(); k++) { - profile[k + m_key1[m_cliques1[cl][i]].size()] = m_key2[m_cliques2[cl][j]][k]; - } - solution[cl].push_back(profile); + for (size_t k = 1; k <= m_key1[m_cliques1[p_clique][i]].size(); k++) { + profile[k] = m_key1[m_cliques1[p_clique][i]][k]; + } + for (size_t k = 1; k <= m_key2[m_cliques2[p_clique][j]].size(); k++) { + profile[k + m_key1[m_cliques1[p_clique][i]].size()] = m_key2[m_cliques2[p_clique][j]][k]; } + profiles.push_back(profile); } } - return solution; + return profiles; +} + +template +Array>>> EnumMixedStrategySolution::GetComponents() const +{ + EnsureCliques(); + + const auto numCliques = m_cliques1.size(); + std::vector parent(numCliques + 1); + std::iota(parent.begin(), parent.end(), 0); + for (size_t cl = 1; cl <= numCliques; cl++) { + for (size_t other = cl + 1; other <= numCliques; other++) { + if (SharesElement(m_cliques1[cl], m_cliques1[other]) || + SharesElement(m_cliques2[cl], m_cliques2[other])) { + UnionNodes(parent, static_cast(cl), static_cast(other)); + } + } + } + + std::map>>> componentsByRoot; + std::vector rootOrder; + for (size_t cl = 1; cl <= numCliques; cl++) { + const int root = FindRoot(parent, static_cast(cl)); + if (componentsByRoot.find(root) == componentsByRoot.end()) { + rootOrder.push_back(root); + } + componentsByRoot[root].push_back(BuildCliqueProfiles(cl)); + } + + Array>>> components; + for (const int root : rootOrder) { + components.push_back(componentsByRoot[root]); + } + return components; } template diff --git a/src/solvers/enummixed/enummixed.h b/src/solvers/enummixed/enummixed.h index dcfb1e1984..538a1d96c1 100644 --- a/src/solvers/enummixed/enummixed.h +++ b/src/solvers/enummixed/enummixed.h @@ -44,7 +44,7 @@ template class EnumMixedStrategySolution { return m_extremeEquilibria; } - Array>> GetCliques() const; + Array>>> GetComponents() const; Game m_game; std::list> m_extremeEquilibria; @@ -60,6 +60,12 @@ template class EnumMixedStrategySolution { /// Representation of the connectedness of the extreme equilibria /// These are generated only on demand mutable Array> m_cliques1, m_cliques2; + +private: + /// Populates m_cliques1/m_cliques2 on first use. + void EnsureCliques() const; + /// Builds the vertex profiles for one maximal clique. + Array> BuildCliqueProfiles(size_t p_clique) const; }; template diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 05c328e7ce..a83e0be353 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -57,6 +57,28 @@ def nfg_coordination_text() -> str: return game.to_nfg() +@pytest.fixture +def nfg_multi_polytope_component_text() -> str: + """A 3x3 table game (found by random search) whose three pure-strategy + equilibria lie in a single connected component made of two polytopes sharing + a vertex -- useful for confirming that `-c/--cliques` output still labels each + polytope separately (each is genuinely convex; their union is not) rather than + collapsing touching polytopes under one label. + """ + payoffs1 = [[1, 0, -2], [0, 2, -2], [1, 2, 0]] + payoffs2 = [[-1, -1, -2], [0, -2, -1], [0, -1, 0]] + game = gbt.Game.new_table([3, 3]) + game.title = "Multi-polytope component" + for i in range(3): + for j in range(3): + game.make_outcome( + {"1": str(i + 1), "2": str(j + 1)}, + {"1": payoffs1[i][j], "2": payoffs2[i][j]}, + f"o{i}{j}", + ) + return game.to_nfg() + + @pytest.fixture def nfg_asymmetric_table_text() -> str: """A 2x2 coordination-style table game whose payoffs aren't symmetric across diff --git a/tests/cli/test_enummixed.py b/tests/cli/test_enummixed.py index e9cb6c1753..5603343116 100644 --- a/tests/cli/test_enummixed.py +++ b/tests/cli/test_enummixed.py @@ -41,3 +41,21 @@ def test_without_cliques_flag_no_convex_lines_appear(cli_runner, nfg_coordinatio result = cli_runner.invoke(enummixed.main, ["-q"], input=nfg_coordination_text) assert result.exit_code == 0 assert "convex" not in result.stdout + + +def test_cliques_flag_does_not_merge_polytopes_sharing_a_vertex( + cli_runner, nfg_multi_polytope_component_text +): + """Two polytopes that touch at a shared vertex are connected but their union is + not itself convex -- `-c` must keep labeling them as separate convex-N sets, + not merge them under one label just because they belong to the same connected + component.""" + result = cli_runner.invoke( + enummixed.main, ["-q", "-c"], input=nfg_multi_polytope_component_text + ) + assert result.exit_code == 0 + labels = [line.split(",")[0] for line in result.stdout.strip().splitlines()] + assert labels.count("NE") == 3 + assert set(labels) == {"NE", "convex-1", "convex-2"} + assert labels.count("convex-1") == 2 + assert labels.count("convex-2") == 2 diff --git a/tests/test_nash.py b/tests/test_nash.py index b8a250cad0..1b766a2d85 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -3784,3 +3784,61 @@ def _fake_run(cmd, **kwargs): monkeypatch.setattr("pygambit.nashphc.subprocess.run", _fake_run) res = gbt.nash.enumpoly_solve(game, phcpack_path="./phc") assert res.use_strategic is True + + +@pytest.mark.parametrize("rational", [True, False]) +def test_enummixed_solve_components_structure(rational): + """`enummixed_solve(..., components=True)` on a game with a single, isolated + equilibrium should report exactly one component containing exactly one + polytope whose vertex is that equilibrium.""" + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 1, "2": -1}, "a") + game.make_outcome({"1": "1", "2": "2"}, {"1": -1, "2": 1}, "b") + game.make_outcome({"1": "2", "2": "1"}, {"1": -1, "2": 1}, "c") + game.make_outcome({"1": "2", "2": "2"}, {"1": 1, "2": -1}, "d") + + res = gbt.nash.enummixed_solve(game, rational=rational, components=True) + + assert len(res.equilibria) == 1 + assert len(res.components) == 1 + component = res.components[0] + assert isinstance(component, gbt.nash.NashComponent) + assert len(component.polytopes) == 1 + polytope = component.polytopes[0] + assert isinstance(polytope, gbt.nash.NashPolytope) + assert polytope.vertices == res.equilibria + assert component.vertices == res.equilibria + + +def test_enummixed_solve_components_groups_polytopes_sharing_a_vertex(): + """A component can be the union of more than one maximal-clique polytope, + glued together at a shared vertex; grouping by connectedness should collapse + those polytopes into a single `NashComponent` rather than reporting them as + unrelated cliques. This game (found by random search) has three pure-strategy + equilibria that lie in one connected component made of two polytopes sharing + the equilibrium (player 1 plays strategy 3, player 2 plays strategy 1).""" + payoffs1 = [[1, 0, -2], [0, 2, -2], [1, 2, 0]] + payoffs2 = [[-1, -1, -2], [0, -2, -1], [0, -1, 0]] + game = gbt.Game.new_table([3, 3]) + for i in range(3): + for j in range(3): + game.make_outcome( + {"1": str(i + 1), "2": str(j + 1)}, + {"1": payoffs1[i][j], "2": payoffs2[i][j]}, + f"o{i}{j}", + ) + + res = gbt.nash.enummixed_solve(game, rational=True, components=True) + + assert len(res.equilibria) == 3 + assert len(res.components) == 1 + component = res.components[0] + assert len(component.polytopes) == 2 + + shared = [v for v in component.polytopes[0].vertices if v in component.polytopes[1].vertices] + assert len(shared) == 1 + + # The union of vertices across the two polytopes, deduplicated at the shared + # vertex, reproduces the full set of extreme equilibria. + assert len(component.vertices) == len(res.equilibria) + assert all(eq in component.vertices for eq in res.equilibria)