From b9b23eb0c3b6f36bf1a969b4d2991684e5658ad4 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 27 May 2026 22:00:35 +0100 Subject: [PATCH 1/6] Erase children's m_low entries after merging in BridgeVisitor::OnExit --- src/games/gametree.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index e82d52f08..5c5b1272d 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,6 +1210,7 @@ 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)) { From 28af6c373872d89d002099cd79a330f686470e24 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 2 Jun 2026 18:04:26 +0100 Subject: [PATCH 2/6] Fix: reject spurious subgame roots on single-action chains --- src/games/gametree.cc | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 5c5b1272d..c28602b22 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1214,7 +1214,27 @@ void GameTreeRep::BuildSubgameRoots() const } if (low == m_disc.at(node)) { - m_subgames.push_back(node); + // low == disc is an exact bridge test only when nodes have distinct terminal spans. + // On a single-action chain the spans collapse, so reject a node satisfying low == disc + // if an absent-minded infoset above it contains a member in the node's subtree. + bool spurious = false; + for (auto *anc = node->m_parent; anc && anc->m_children.size() == 1; anc = anc->m_parent) { + if (anc->m_infoset->m_members.size() < 2) { + continue; + } + for (const auto &member : anc->m_infoset->m_members) { + if (member.get() != anc && member->IsSuccessorOf(p_node)) { + spurious = true; + break; + } + } + if (spurious) { + break; + } + } + if (!spurious) { + m_subgames.push_back(node); + } } return DFSCallbackResult::Continue; From 9b568455fc3419e6268f6f03b7574e7e1f43ae1d Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 2 Jun 2026 18:05:42 +0100 Subject: [PATCH 3/6] Add two test games and update the subgame test suite --- tests/test_games/AM-unary-branches.efg | 14 ++++++++++++++ tests/test_games/AM-unary-hops.efg | 13 +++++++++++++ tests/test_node.py | 14 ++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 tests/test_games/AM-unary-branches.efg create mode 100644 tests/test_games/AM-unary-hops.efg diff --git a/tests/test_games/AM-unary-branches.efg b/tests/test_games/AM-unary-branches.efg new file mode 100644 index 000000000..fc545c08c --- /dev/null +++ b/tests/test_games/AM-unary-branches.efg @@ -0,0 +1,14 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +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..22257c5d2 --- /dev/null +++ b/tests/test_games/AM-unary-hops.efg @@ -0,0 +1,13 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +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" + ), ] From c0013c41d924928ffe77858a73f229a25c42ac20 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 3 Jun 2026 11:32:01 +0100 Subject: [PATCH 4/6] refactor the check using std::any_of and a lambda function --- src/games/gametree.cc | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index c28602b22..4dc51f390 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1218,19 +1218,13 @@ void GameTreeRep::BuildSubgameRoots() const // On a single-action chain the spans collapse, so reject a node satisfying low == disc // if an absent-minded infoset above it contains a member in the node's subtree. bool spurious = false; - for (auto *anc = node->m_parent; anc && anc->m_children.size() == 1; anc = anc->m_parent) { - if (anc->m_infoset->m_members.size() < 2) { - continue; - } - for (const auto &member : anc->m_infoset->m_members) { - if (member.get() != anc && member->IsSuccessorOf(p_node)) { - spurious = true; - break; - } - } - if (spurious) { - break; - } + 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); From 48ef9e2a41904295bf92eecf84289c5feb516cda Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 3 Jun 2026 11:59:44 +0100 Subject: [PATCH 5/6] Include as the games' extended description a reason why these games exist in the test suite --- tests/test_games/AM-unary-branches.efg | 7 +++++-- tests/test_games/AM-unary-hops.efg | 10 ++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_games/AM-unary-branches.efg b/tests/test_games/AM-unary-branches.efg index fc545c08c..348052143 100644 --- a/tests/test_games/AM-unary-branches.efg +++ b/tests/test_games/AM-unary-branches.efg @@ -1,5 +1,8 @@ -EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } -"" +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 diff --git a/tests/test_games/AM-unary-hops.efg b/tests/test_games/AM-unary-hops.efg index 22257c5d2..60042f48a 100644 --- a/tests/test_games/AM-unary-hops.efg +++ b/tests/test_games/AM-unary-hops.efg @@ -1,5 +1,11 @@ -EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } -"" +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 From 7f8be56542f781f69d12b10e366de76cc2babc2b Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 4 Jun 2026 08:54:55 +0100 Subject: [PATCH 6/6] Improve the comment for the `low == disc` test producing false positives --- src/games/gametree.cc | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 4dc51f390..85a79d94b 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1214,9 +1214,11 @@ void GameTreeRep::BuildSubgameRoots() const } if (low == m_disc.at(node)) { - // low == disc is an exact bridge test only when nodes have distinct terminal spans. - // On a single-action chain the spans collapse, so reject a node satisfying low == disc - // if an absent-minded infoset above it contains a member in the node's subtree. + // 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) {