diff --git a/src/games/gametree.cc b/src/games/gametree.cc index e82d52f08..85a79d94b 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1185,7 +1185,7 @@ void GameTreeRep::BuildSubgameRoots() const // Phase 2: Reachability and detection struct BridgeVisitor { - const std::unordered_map &m_disc; + std::unordered_map &m_disc; const std::unordered_map &m_hull; std::vector &m_subgames; std::unordered_map m_low; @@ -1210,10 +1210,27 @@ void GameTreeRep::BuildSubgameRoots() const for (const auto &child : p_node->GetChildren()) { low.Merge(m_low.at(child.get())); + m_low.erase(child.get()); } if (low == m_disc.at(node)) { - m_subgames.push_back(node); + // The `low == disc` test is exact only with distinct terminal spans. A single-action + // chain above a candidate node collapses the spans and can create false positives. + // Reject a node if some single-action ancestor's infoset (possibly the node's own) + // has a member in the node's subtree (possibly the node itself). + // Note that such an infoset is necessarily absent-minded. + bool spurious = false; + for (auto *anc = node->m_parent; anc && anc->m_children.size() == 1 && !spurious; + anc = anc->m_parent) { + const auto &members = anc->m_infoset->m_members; + spurious = members.size() >= 2 && + std::any_of(members.begin(), members.end(), [&](const auto &member) { + return member.get() != anc && member->IsSuccessorOf(p_node); + }); + } + if (!spurious) { + m_subgames.push_back(node); + } } return DFSCallbackResult::Continue; diff --git a/tests/test_games/AM-unary-branches.efg b/tests/test_games/AM-unary-branches.efg new file mode 100644 index 000000000..348052143 --- /dev/null +++ b/tests/test_games/AM-unary-branches.efg @@ -0,0 +1,17 @@ +EFG 2 R "Unary chain with AM-infosets: Example 2" { "Player 1" "Player 2" } +"A tailored edge case for the interval Tarjan subgame detector: +low == disc is an exact bridge test only when nodes have distinct terminal spans. +On this single-action chain an absent-minded infoset collapses the spans, so the +Player 1 branching node passes the low == disc test but is not a subgame root." + +p "" 2 1 "" { "T" "B" } 0 +p "" 1 1 "" { "1" } 0 +p "" 1 1 "" { "1" } 0 +p "" 1 2 "" { "1" } 0 +p "" 2 2 "" { "1" } 0 +p "" 1 3 "" { "t" "b" } 0 +p "" 2 2 "" { "1" } 0 +t "" 1 "Outcome 1" { 1, -1 } +t "" 2 "Outcome 2" { 2, -2 } +p "" 1 2 "" { "1" } 0 +t "" 3 "Outcome 3" { 3, -3 } diff --git a/tests/test_games/AM-unary-hops.efg b/tests/test_games/AM-unary-hops.efg new file mode 100644 index 000000000..60042f48a --- /dev/null +++ b/tests/test_games/AM-unary-hops.efg @@ -0,0 +1,19 @@ +EFG 2 R "Unary chain with AM-infosets: Example 1" { "Player 1" "Player 2" } +"A tailored edge case for the interval Tarjan subgame detector: +low == disc is an exact bridge test only when nodes have distinct terminal spans. +This single-action chain carries several absent-minded infosets that collapse the spans, +so several nodes pass the low == disc test without being subgame roots: among them an +interior chain node straddled by one absent-minded set while it is the upper member of +another, and the Player 1 branching node straddled by a member nested below it. +The only true subgame roots are the game root and the two Player 2 nodes." + +p "" 1 1 "" { "1" } 0 +p "" 1 1 "" { "1" } 0 +p "" 2 1 "" { "1" } 0 +p "" 1 2 "" { "1" } 0 +p "" 2 1 "" { "1" } 0 +p "" 1 3 "" { "T" "B" } 0 +p "" 2 2 "" { "1" } 0 +t "" 1 "Outcome 1" { 1, -1 } +p "" 1 2 "" { "1" } 0 +t "" 2 "Outcome 2" { 2, -2 } diff --git a/tests/test_node.py b/tests/test_node.py index 8478c5b5a..36b50880d 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -198,6 +198,20 @@ class SubgameRootsTestCase: ), id="Absent-minded-game-with-paths-intersecting-infoset-three-times" ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-unary-hops.efg"), + expected_paths=[[], ["1", "1"], ["T", "1", "1", "1", "1", "1"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-unary-branches.efg"), + expected_paths=[[], ["1", "1", "1", "T"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), ]