Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3b7ffe9
Refactor the legacy subgame root finder using DSU (Disjoint Set Union…
d-kad Dec 3, 2025
eafc248
Add tests following the new path style for nodes and new .efg files
d-kad Dec 4, 2025
9bd1e26
Add missing .efg file
d-kad Dec 4, 2025
d3e7172
Delete an outdated subgame roots test in the legacy test suite
d-kad Dec 4, 2025
e58f7e9
Refactor Subgame Root Finder: replace IDs with object pointers and im…
d-kad Dec 6, 2025
49bb98e
Refactor the legacy implementation of IsSubgameRoot
d-kad Dec 6, 2025
861beae
Eliminated visited tracking for infosets
d-kad Dec 7, 2025
e9ca49b
Refactor: Replace priority queues with indexed arrays
d-kad Dec 8, 2025
dd47f40
Consolidate two subgame root tests into a single path-based verification
d-kad Dec 8, 2025
aee924d
Refactor the local frontier to be a priority queue
d-kad Dec 11, 2025
eeb32ea
Tests updated to reflect trivial game edge case.
d-kad Dec 11, 2025
8beba49
Raise UndefinedException in virtual GetSubgameRoot() in the base clas…
d-kad Dec 11, 2025
efe95bc
Replace token-based visited tracking with std::unordered_set; correct…
d-kad Dec 11, 2025
599f41c
Use the post-order iterator in FindSubgameRoots
d-kad Dec 16, 2025
9d68d1f
Tightened tests
d-kad Dec 17, 2025
4f3033d
Apply filter_if, GetPlayersWithChance and GetNonterminalNodes tighten…
d-kad Dec 17, 2025
1216bec
Refactor subgame root finder with NestedElementCollection and filter_if
d-kad Dec 18, 2025
f6e1f68
Pre-compute DFS numbers locally within FindSubgameRoots() to eliminat…
d-kad Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/games/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,8 @@ class GameRep : public std::enable_shared_from_this<GameRep> {
}
return false;
}
///
virtual GameNode GetSubgameRoot(const GameInfoset &) const { throw UndefinedException(); }
//@}

/// @name Writing data files
Expand Down
234 changes: 209 additions & 25 deletions src/games/gametree.cc
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
#include <algorithm>
#include <functional>
#include <numeric>
#include <queue>
#include <stack>
#include <set>
#include <variant>
#include <unordered_set>

#include "gambit.h"
#include "gametree.h"
Expand Down Expand Up @@ -371,32 +373,10 @@ bool GameNodeRep::IsSuccessorOf(GameNode p_node) const

bool GameNodeRep::IsSubgameRoot() const
{
// First take care of a couple easy cases
if (m_children.empty() || m_infoset->m_members.size() > 1) {
return false;
}
if (!m_parent) {
return true;
}

// A node is a subgame root if and only if in every information set,
// either all members succeed the node in the tree,
// or all members do not succeed the node in the tree.
for (auto player : m_game->GetPlayers()) {
for (auto infoset : player->GetInfosets()) {
const bool precedes = infoset->m_members.front()->IsSuccessorOf(
std::const_pointer_cast<GameNodeRep>(shared_from_this()));
if (std::any_of(std::next(infoset->m_members.begin()), infoset->m_members.end(),
[this, precedes](const std::shared_ptr<GameNodeRep> &m) {
return m->IsSuccessorOf(std::const_pointer_cast<GameNodeRep>(
shared_from_this())) != precedes;
})) {
return false;
}
}
if (m_children.empty()) {
return !GetParent();
}

return true;
return m_game->GetSubgameRoot(m_infoset->shared_from_this()) == shared_from_this();
}

bool GameNodeRep::IsStrategyReachable() const
Expand Down Expand Up @@ -884,6 +864,7 @@ void GameTreeRep::ClearComputedValues() const
m_ownPriorActionInfo = nullptr;
const_cast<GameTreeRep *>(this)->m_unreachableNodes = nullptr;
m_absentMindedInfosets.clear();
m_infosetSubgameRoot.clear();
m_computedValues = false;
}

Expand Down Expand Up @@ -1093,6 +1074,209 @@ void GameTreeRep::BuildUnreachableNodes() const
}
}

//------------------------------------------------------------------------
// Subgame Root Finder
//------------------------------------------------------------------------

namespace { // Anonymous namespace
struct SubgameScratchData {
/// DSU structures
///
/// Maps each infoset to its parent in the union-find structure.
/// After path compression, points to the component leader directly.
std::unordered_map<GameInfosetRep *, GameInfosetRep *> dsu_parent;
/// Maps each component leader to the highest node (candidate root) in that component
std::unordered_map<GameInfosetRep *, GameNodeRep *> subgame_root_candidate;

/// DSU Find + path compression: Finds the leader of the component containing p_infoset.
///
/// @param p_infoset The infoset to find the component leader for
/// @return Pointer to the leader infoset of the component
///
/// Postcondition: Path from p_infoset to leader is compressed (flattened)
GameInfosetRep *FindSet(GameInfosetRep *p_infoset)
{
// Initialize/retrieve current parent
auto *leader = dsu_parent.try_emplace(p_infoset, p_infoset).first->second;
while (dsu_parent.at(leader) != leader) {
leader = dsu_parent.at(leader);
}
// Path compression
auto *curr = p_infoset;
while (curr != leader) {
auto &parent_ref = dsu_parent.at(curr);
curr = parent_ref;
parent_ref = leader;
}
return leader;
}

/// DSU Union: Merges the components containing two infosets, updates the subgame root candidate.
///
/// @param p_start_infoset The infoset whose component should absorb the other
/// @param p_current_infoset The infoset whose component is being merged
/// @param p_current_node The node being processed, considered as potential subgame root
///
/// Postcondition: Both infosets belong to the same component
/// Postcondition: The component's subgame_root_candidate is updated to the highest node
void UnionSets(GameInfosetRep *p_start_infoset, GameInfosetRep *p_current_infoset,
GameNodeRep *p_current_node)
{
auto *leader_start = FindSet(p_start_infoset);
auto *leader_current = FindSet(p_current_infoset);

if (leader_start == leader_current) {
subgame_root_candidate[leader_start] = p_current_node;
return;
}

// Check if candidate exists before accessing
auto it = subgame_root_candidate.find(leader_current);
auto *existing_candidate = (it != subgame_root_candidate.end()) ? it->second : nullptr;
auto *best_candidate = existing_candidate ? existing_candidate : p_current_node;

dsu_parent[leader_current] = leader_start;
subgame_root_candidate[leader_start] = best_candidate;
}
};

/// Generates a single connected component starting from a given node.
///
/// The local frontier driving the exploration is a priority queue.
/// This design choice ensures that nodes are processed before their ancestors.
///
/// Starting from p_start_node, explores the game tree by:
/// 1. Adding all members of each encountered infoset (horizontal expansion of the frontier)
/// 2. Moving to parent nodes (vertical expansion of the frontier)
/// 3. When hitting nodes from previously-generated components (external collision)
/// it merges the components and adds the root of the external component to the frontier
///
/// The component gathers all infosets that share the same minimal subgame root.
/// The highest node processed that empties the frontier without adding any new nodes
/// to horizontal expansion becomes the subgame root candidate.
///
/// @param p_data The DSU data structure to update with component information
/// @param p_start_node The node to start component generation from
/// @param p_node_ordering A map providing a strict total ordering (DFS Preorder) of nodes
///
/// Precondition: p_start_node must be non-terminal
/// Precondition: p_start_node's infoset must not already be in p_data.dsu_parent
/// Postcondition: p_data.subgame_root_candidate[leader] contains the highest node
/// in the newly-formed component
void GenerateComponent(SubgameScratchData &p_data, GameNodeRep *p_start_node,
const std::unordered_map<const GameNodeRep *, int> &p_node_ordering)
{
auto node_cmp = [&p_node_ordering](const GameNodeRep *a, const GameNodeRep *b) {
return p_node_ordering.at(a) < p_node_ordering.at(b);
};

std::priority_queue<GameNodeRep *, std::vector<GameNodeRep *>, decltype(node_cmp)>
local_frontier(node_cmp);

std::unordered_set<GameNodeRep *> visited_this_component;

local_frontier.push(p_start_node);
visited_this_component.insert(p_start_node);
auto *start_infoset = p_start_node->GetInfoset().get();

while (!local_frontier.empty()) {
auto *curr = local_frontier.top();
local_frontier.pop();

auto *curr_infoset = curr->GetInfoset().get();
const bool is_external_collision = p_data.dsu_parent.count(curr_infoset);

p_data.UnionSets(start_infoset, curr_infoset, curr);

if (is_external_collision) {
// We hit a node belonging to a previously generated component.
auto *candidate_root = p_data.subgame_root_candidate.at(p_data.FindSet(curr_infoset));
if (!visited_this_component.count(candidate_root)) {
local_frontier.push(candidate_root);
visited_this_component.insert(candidate_root);
}
}
else {
// First time seeing this infoset: add all its members to the frontier.
for (const auto &member_sp : filter_if(curr->GetInfoset()->GetMembers(),
[curr](const auto &m) { return m.get() != curr; })) {
auto *member = member_sp.get();
local_frontier.push(member);
visited_this_component.insert(member);
}
}

if (!local_frontier.empty()) {
if (auto parent_sp = curr->GetParent()) {
auto *parent = parent_sp.get();
if (!visited_this_component.count(parent)) {
local_frontier.push(parent);
visited_this_component.insert(parent);
}
}
}
}
}

/// For each infoset in the game, computes the root of the smallest subgame containing it.
///
/// Processes nodes in reverse DFS order (postorder), building a component for each node,
/// skipping a node if it is:
/// 1. A member of an infoset already belonging to some component, or
/// 2. Terminal
///
/// @param p_game The game tree
/// @return Map from each infoset to the root of its smallest containing subgame
///
/// Precondition: p_game must be a valid game tree
/// Postcondition: Every infoset in the game is mapped to exactly one subgame root node
/// Returns: Empty map if the game root is terminal (trivial game)
std::map<GameInfosetRep *, GameNodeRep *> FindSubgameRoots(const Game &p_game)
{
if (p_game->GetRoot()->IsTerminal()) {
return {};
}

// Pre-compute DFS numbers locally.
std::unordered_map<const GameNodeRep *, int> node_ordering;
int counter = 0;
for (const auto &node : p_game->GetNodes(TraversalOrder::Preorder)) {
node_ordering[node.get()] = counter++;
}

SubgameScratchData data;

// Define filter predicate
auto is_unvisited_infoset = [&data](const auto &node) {
return !data.dsu_parent.count(node->GetInfoset().get());
};

// Process nodes in postorder
for (const auto &node :
filter_if(p_game->GetNonterminalNodes(TraversalOrder::Postorder), is_unvisited_infoset)) {
GenerateComponent(data, node.get(), node_ordering);
}

std::map<GameInfosetRep *, GameNodeRep *> result;

using InfosetsWithChance =
NestedElementCollection<Game, &GameRep::GetPlayersWithChance, &GamePlayerRep::GetInfosets>;

for (const auto &infoset : InfosetsWithChance(p_game)) {
auto *ptr = infoset.get();
result[ptr] = data.subgame_root_candidate.at(data.FindSet(ptr));
}

return result;
}

} // end anonymous namespace

void GameTreeRep::BuildSubgameRoots() const
{
m_infosetSubgameRoot = FindSubgameRoots(std::const_pointer_cast<GameRep>(shared_from_this()));
}

//------------------------------------------------------------------------
// GameTreeRep: Writing data files
//------------------------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions src/games/gametree.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class GameTreeRep : public GameExplicitRep {
mutable std::shared_ptr<OwnPriorActionInfo> m_ownPriorActionInfo;
mutable std::unique_ptr<std::set<GameNodeRep *>> m_unreachableNodes;
mutable std::set<GameInfosetRep *> m_absentMindedInfosets;
mutable std::map<GameInfosetRep *, GameNodeRep *> m_infosetSubgameRoot;

/// @name Private auxiliary functions
//@{
Expand Down Expand Up @@ -88,6 +89,13 @@ class GameTreeRep : public GameExplicitRep {
/// Returns the largest payoff to the player in any play of the game
Rational GetPlayerMaxPayoff(const GamePlayer &) const override;
bool IsAbsentMinded(const GameInfoset &p_infoset) const override;
GameNode GetSubgameRoot(const GameInfoset &infoset) const override
{
if (m_infosetSubgameRoot.empty()) {
const_cast<GameTreeRep *>(this)->BuildSubgameRoots();
}
return {m_infosetSubgameRoot.at(infoset.get())->shared_from_this()};
}
//@}

/// @name Players
Expand Down Expand Up @@ -174,6 +182,7 @@ class GameTreeRep : public GameExplicitRep {
std::vector<GameNodeRep *> BuildConsistentPlaysRecursiveImpl(GameNodeRep *node);
void BuildOwnPriorActions() const;
void BuildUnreachableNodes() const;
void BuildSubgameRoots() const;
};

template <class T> class TreeMixedStrategyProfileRep : public MixedStrategyProfileRep<T> {
Expand Down
1 change: 1 addition & 0 deletions src/pygambit/gambit.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ cdef extern from "games/game.h":
stdvector[c_GameNode] GetPlays(c_GameAction) except +
bool IsPerfectRecall() except +
bool IsAbsentMinded(c_GameInfoset) except +
c_GameNode GetSubgameRoot(c_GameInfoset) except +

c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, int) except +ValueError
c_GameInfoset AppendMove(c_GameNode, c_GameInfoset) except +ValueError
Expand Down
4 changes: 4 additions & 0 deletions src/pygambit/game.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,10 @@ class Game:
"""
return self.game.deref().IsPerfectRecall()

def subgame_root(self, infoset: typing.Union[Infoset, str]) -> Node:
infoset = self._resolve_infoset(infoset, "subgame_root")
return Node.wrap(self.game.deref().GetSubgameRoot(cython.cast(Infoset, infoset).infoset))

@property
def min_payoff(self) -> decimal.Decimal | Rational:
"""The minimum payoff to any player in any play of the game.
Expand Down
14 changes: 14 additions & 0 deletions tests/test_games/AM-subgames.efg
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" }
""

p "" 1 1 "" { "1" "2" } 0
p "" 1 1 "" { "1" "2" } 0
p "" 2 1 "" { "1" "2" } 0
t "" 1 "Outcome 1" { 1, -1 }
t "" 2 "Outcome 2" { 2, -2 }
p "" 2 3 "" { "1" "2" } 0
t "" 3 "Outcome 3" { 3, -3 }
t "" 4 "Outcome 4" { 4, -4 }
p "" 2 2 "" { "1" "2" } 0
t "" 5 "Outcome 5" { 5, -5 }
t "" 6 "Outcome 6" { 6, -6 }
55 changes: 55 additions & 0 deletions tests/test_games/subgame-roots-finder-multiple-merges.efg
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" }
""

p "" 1 1 "" { "1" "1" } 0
t "" 1 "Outcome 1" { 1, -1 }
p "" 2 1 "" { "1" "1" "1" "1" "1" } 0
p "" 1 2 "" { "1" "1" } 0
t "" 2 "Outcome 2" { 2, -2 }
p "" 1 3 "" { "1" "1" } 0
t "" 3 "Outcome 3" { 3, -3 }
t "" 4 "Outcome 4" { 4, -4 }
p "" 1 3 "" { "1" "1" } 0
p "" 1 2 "" { "1" "1" } 0
t "" 5 "Outcome 5" { 5, -5 }
t "" 6 "Outcome 6" { 6, -6 }
t "" 7 "Outcome 7" { 7, -7 }
p "" 2 2 "" { "1" "1" } 0
t "" 8 "Outcome 8" { 8, -8 }
p "" 1 4 "" { "1" "1" } 0
p "" 2 3 "" { "1" "1" } 0
t "" 9 "Outcome 9" { 9, -9 }
t "" 10 "Outcome 10" { 10, -10 }
p "" 2 4 "" { "1" "1" } 0
t "" 11 "Outcome 11" { 11, -11 }
p "" 1 5 "" { "1" "1" } 0
p "" 1 6 "" { "1" "1" } 0
p "" 2 5 "" { "1" "1" } 0
t "" 12 "Outcome 12" { 12, -12 }
t "" 13 "Outcome 13" { 13, -13 }
t "" 14 "Outcome 14" { 14, -14 }
p "" 1 6 "" { "1" "1" } 0
p "" 2 6 "" { "1" "1" } 0
c "" 1 "" { "1" 1/2 "1" 1/2 } 0
p "" 2 5 "" { "1" "1" } 0
p "" 2 3 "" { "1" "1" } 0
t "" 15 "Outcome 15" { 15, -15 }
t "" 16 "Outcome 16" { 16, -16 }
t "" 17 "Outcome 17" { 17, -17 }
p "" 2 5 "" { "1" "1" } 0
t "" 18 "Outcome 18" { 18, -18 }
t "" 19 "Outcome 19" { 19, -19 }
t "" 20 "Outcome 20" { 20, -20 }
p "" 2 6 "" { "1" "1" } 0
p "" 2 7 "" { "1" "1" } 0
t "" 21 "Outcome 21" { 21, -21 }
t "" 22 "Outcome 22" { 22, -22 }
p "" 2 7 "" { "1" "1" } 0
t "" 23 "Outcome 23" { 23, -23 }
t "" 24 "Outcome 24" { 24, -24 }
p "" 1 7 "" { "1" "1" } 0
t "" 25 "Outcome 25" { 25, -25 }
t "" 26 "Outcome 26" { 26, -26 }
p "" 1 7 "" { "1" "1" } 0
t "" 27 "Outcome 27" { 27, -27 }
t "" 28 "Outcome 28" { 28, -28 }
Loading
Loading