// Copyright 2010-2025 Google LLC // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #include "ortools/algorithms/find_graph_symmetries.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "absl/numeric/bits.h" #include "absl/random/distributions.h" #include "absl/random/random.h" #include "absl/status/statusor.h" #include "absl/strings/match.h" #include "absl/strings/str_cat.h" #include "absl/strings/str_join.h" #include "absl/strings/str_split.h" #include "absl/strings/string_view.h" #include "absl/time/clock.h" #include "absl/time/time.h" #include "absl/types/span.h" #include "gtest/gtest.h" #include "ortools/algorithms/dynamic_partition.h" #include "ortools/algorithms/dynamic_permutation.h" #include "ortools/algorithms/sparse_permutation.h" #include "ortools/base/dump_vars.h" #include "ortools/base/gmock.h" #include "ortools/base/helpers.h" #include "ortools/base/map_util.h" #include "ortools/base/path.h" #include "ortools/graph/graph_io.h" #include "ortools/graph/util.h" namespace operations_research { namespace { using Graph = GraphSymmetryFinder::Graph; using ::testing::AnyOf; using ::testing::DoubleEq; using ::testing::ElementsAre; using ::testing::IsEmpty; using ::testing::UnorderedElementsAre; using ::util::GraphIsSymmetric; // Shortcut that calls RecursivelyRefinePartitionByAdjacency() on all nodes // of a graph, and outputs the resulting partition. std::string FullyRefineGraph(absl::Span> arcs) { Graph graph; for (const std::pair& arc : arcs) { graph.AddArc(arc.first, arc.second); } graph.Build(); GraphSymmetryFinder symmetry_finder(graph, GraphIsSymmetric(graph)); DynamicPartition partition(graph.num_nodes()); symmetry_finder.RecursivelyRefinePartitionByAdjacency(0, &partition); return partition.DebugString(/*sort_parts_lexicographically=*/true); } TEST(RecursivelyRefinePartitionByAdjacencyTest, DoublyLinkedChain) { // Graph: 0 <-> 1 <-> 2 <-> 3 <-> 4 EXPECT_EQ( "0 4 | 1 3 | 2", FullyRefineGraph( {{0, 1}, {1, 0}, {1, 2}, {2, 1}, {2, 3}, {3, 2}, {3, 4}, {4, 3}})); } TEST(RecursivelyRefinePartitionByAdjacencyTest, Singleton) { EXPECT_EQ("0", FullyRefineGraph({{0, 0}})); } TEST(RecursivelyRefinePartitionByAdjacencyTest, Clique) { EXPECT_EQ("0 1 2 3", FullyRefineGraph({{0, 1}, {0, 2}, {0, 3}, {1, 0}, {1, 2}, {1, 3}, {2, 0}, {2, 1}, {2, 3}, {3, 0}, {3, 1}, {3, 2}})); } TEST(RecursivelyRefinePartitionByAdjacencyTest, CyclesOfDifferentLength) { // The 1-2-3 and 4-5 cycles aren't differentiated: that's precisely the // limitation of the refinement algorithm. All these nodes have 1 incoming and // 1 outgoing arc. EXPECT_EQ("0 | 1 2 3 4 5", FullyRefineGraph({{1, 2}, {2, 3}, {3, 1}, {4, 5}, {5, 4}})); } TEST(RecursivelyRefinePartitionByAdjacencyTest, Chain) { EXPECT_EQ("0 | 1 | 2 | 3 | 4", FullyRefineGraph({{0, 1}, {1, 2}, {2, 3}, {3, 4}})); } TEST(RecursivelyRefinePartitionByAdjacencyTest, FlowerOfCycles) { // A bunch of cycles of different or same sizes that all share node 0. // Note(user): this is only fully refined because we refine both on outwards // and inward adjacency of node parts. EXPECT_EQ("0 | 1 4 | 2 5 | 3 6 | 7 | 8 | 9", FullyRefineGraph({ {0, 1}, {1, 0}, // 0-1 {0, 2}, {2, 3}, {3, 0}, // 0-2-3 {0, 4}, {4, 0}, // 0-4 {0, 5}, {5, 6}, {6, 0}, // 0-5-6 {0, 7}, {7, 8}, {8, 9}, {9, 0}, // 0-7-8-9 })); } TEST(GraphSymmetryFinderTest, EmptyGraph) { for (bool is_undirected : {true, false}) { SCOPED_TRACE(DUMP_VARS(is_undirected)); Graph empty_graph; empty_graph.Build(); GraphSymmetryFinder symmetry_finder(empty_graph, is_undirected); EXPECT_TRUE(symmetry_finder.IsGraphAutomorphism(DynamicPermutation(0))); std::vector node_equivalence_classes_io; std::vector> generators; std::vector factorized_automorphism_group_size; ASSERT_OK(symmetry_finder.FindSymmetries( &node_equivalence_classes_io, &generators, &factorized_automorphism_group_size)); EXPECT_THAT(node_equivalence_classes_io, IsEmpty()); EXPECT_THAT(generators, IsEmpty()); EXPECT_THAT(factorized_automorphism_group_size, IsEmpty()); } } TEST(GraphSymmetryFinderTest, EmptyGraphAndDoNothing) { Graph empty_graph; empty_graph.Build(); GraphSymmetryFinder symmetry_finder(empty_graph, /*is_undirected=*/true); } class IsGraphAutomorphismTest : public testing::Test { protected: void ExpectIsGraphAutomorphism( int num_nodes, const std::vector>& graph_arcs, absl::Span> permutation_cycles, bool expected_is_automorphism) { Graph graph(num_nodes, graph_arcs.size()); for (const std::pair& arc : graph_arcs) { graph.AddArc(arc.first, arc.second); } graph.Build(); GraphSymmetryFinder symmetry_finder(graph, GraphIsSymmetric(graph)); DynamicPermutation permutation(graph.num_nodes()); for (const std::vector& cycle : permutation_cycles) { std::vector shifted_cycle(cycle.begin() + 1, cycle.end()); shifted_cycle.push_back(cycle[0]); permutation.AddMappings(cycle, shifted_cycle); } const bool is_automorphism = symmetry_finder.IsGraphAutomorphism(permutation); EXPECT_EQ(expected_is_automorphism, is_automorphism) << "\nWith graph: " << absl::StrJoin(graph_arcs, ", ", absl::PairFormatter("->")) << "\nAnd permutation: " << permutation.DebugString(); } }; TEST_F(IsGraphAutomorphismTest, IsolatedNodes) { ExpectIsGraphAutomorphism(3, {}, {{0, 1}}, true); ExpectIsGraphAutomorphism(3, {}, {{1, 2}}, true); ExpectIsGraphAutomorphism(3, {}, {{0, 2}}, true); ExpectIsGraphAutomorphism(3, {}, {{0, 1, 2}}, true); } TEST_F(IsGraphAutomorphismTest, DirectedCyclesOfDifferentLengths) { const std::vector> graph = { {0, 0}, // Length 1 {1, 2}, {2, 1}, // Length 2 {3, 4}, {4, 5}, {5, 3}, // Length 3 {6, 7}, {7, 8}, {8, 9}, {9, 10}, {10, 6}, // Length 5 }; ExpectIsGraphAutomorphism(12, graph, {{0, 10}}, false); ExpectIsGraphAutomorphism(12, graph, {{0, 1}}, false); ExpectIsGraphAutomorphism(12, graph, {{1, 2}}, true); ExpectIsGraphAutomorphism(12, graph, {{3, 4}}, false); ExpectIsGraphAutomorphism(12, graph, {{1, 3}}, false); ExpectIsGraphAutomorphism(12, graph, {{6, 7, 8}}, false); ExpectIsGraphAutomorphism(12, graph, {{6, 8}, {7, 9}}, false); ExpectIsGraphAutomorphism(12, graph, {{6, 7, 8, 9}}, false); ExpectIsGraphAutomorphism(12, graph, {{6, 7, 8, 9, 10}}, true); ExpectIsGraphAutomorphism(12, graph, {{1, 2}, {3, 4, 5}, {6, 7, 8, 9, 10}}, true); ExpectIsGraphAutomorphism(12, graph, {{1, 2}, {3, 4, 5}, {0, 7}}, false); } TEST_F(IsGraphAutomorphismTest, Cliques) { const std::vector> graph = { {0, 0}, // 1 {1, 1}, {1, 2}, {2, 1}, {2, 2}, // 2 {3, 3}, {3, 4}, {3, 5}, {4, 3}, {4, 4}, {4, 5}, {5, 3}, {5, 4}, {5, 5} // 3 }; ExpectIsGraphAutomorphism(6, graph, {{1, 2}}, true); ExpectIsGraphAutomorphism(6, graph, {{3, 4}}, true); ExpectIsGraphAutomorphism(6, graph, {{4, 5}}, true); ExpectIsGraphAutomorphism(6, graph, {{3, 4, 5}}, true); ExpectIsGraphAutomorphism(6, graph, {{1, 3}}, false); } TEST_F(IsGraphAutomorphismTest, UndirectedChains) { const std::vector> graph = { {0, 1}, {1, 0}, // Length 2 {2, 3}, {3, 4}, {4, 5}, {5, 6}, {6, 5}, {5, 4}, {4, 3}, {3, 2}, // Length 5 }; ExpectIsGraphAutomorphism(7, graph, {{0, 1}}, true); ExpectIsGraphAutomorphism(7, graph, {{2, 6}, {3, 5}}, true); ExpectIsGraphAutomorphism(7, graph, {{2, 6}}, false); } class FindSymmetriesTest : public ::testing::Test { protected: std::vector GetDensePermutation(const SparsePermutation& permutation) { std::vector dense_perm(permutation.Size(), -1); for (int i = 0; i < dense_perm.size(); ++i) dense_perm[i] = i; for (int c = 0; c < permutation.NumCycles(); ++c) { // TODO(user): use the global element->image iterator when it exists. int prev = *(permutation.Cycle(c).end() - 1); for (const int e : permutation.Cycle(c)) { dense_perm[prev] = e; prev = e; } } return dense_perm; } std::vector ComposePermutations(absl::Span p1, absl::Span p2) { CHECK_EQ(p1.size(), p2.size()); std::vector composed(p1.size(), -1); for (int i = 0; i < p1.size(); ++i) composed[i] = p1[p2[i]]; return composed; } // Brute-force compute the size of the group by computing all of its elements, // with some basic, non-through EXPECT(..) that check that each generator // does make the group grow. int ComputePermutationGroupSizeAndVerifyBasicIrreductibility( absl::Span> generators) { if (generators.empty()) return 1; // The identity. const int num_nodes = generators[0]->Size(); // The group only contains the identity at first. std::set> permutation_group; permutation_group.insert(GetDensePermutation(SparsePermutation(num_nodes))); // For each generator... for (int i = 0; i < generators.size(); ++i) { const SparsePermutation& perm = *generators[i]; const std::vector dense_perm = GetDensePermutation(perm); auto insertion_result = permutation_group.insert(dense_perm); if (!insertion_result.second) { ADD_FAILURE() << "Unneeded generator: " << perm.DebugString(); continue; } std::vector*> new_perms( 1, &(*insertion_result.first)); while (!new_perms.empty()) { const std::vector* new_perm = new_perms.back(); new_perms.pop_back(); std::vector*> old_perms; old_perms.reserve(permutation_group.size()); for (const std::vector& p : permutation_group) old_perms.push_back(&p); for (const std::vector* old_perm : old_perms) { auto insertion_result = permutation_group.insert( ComposePermutations(*old_perm, *new_perm)); if (insertion_result.second) { new_perms.push_back(&(*insertion_result.first)); } insertion_result = permutation_group.insert( ComposePermutations(*new_perm, *old_perm)); if (insertion_result.second) { new_perms.push_back(&(*insertion_result.first)); } } } } return permutation_group.size(); } static constexpr double kDefaultTimeLimitSeconds = 120.0; void ExpectSymmetries(const std::vector>& arcs, absl::string_view expected_node_equivalence_classes, double log_of_expected_permutation_group_size) { Graph graph; for (const std::pair& arc : arcs) graph.AddArc(arc.first, arc.second); graph.Build(); GraphSymmetryFinder symmetry_finder(graph, GraphIsSymmetric(graph)); std::vector> generators; std::vector node_equivalence_classes(graph.num_nodes(), 0); std::vector orbit_sizes; TimeLimit time_limit(kDefaultTimeLimitSeconds); ASSERT_OK(symmetry_finder.FindSymmetries( &node_equivalence_classes, &generators, &orbit_sizes, &time_limit)); std::vector permutations_str; for (const std::unique_ptr& permutation : generators) { permutations_str.push_back(permutation->DebugString()); } SCOPED_TRACE( "Graph: " + absl::StrJoin(arcs, ", ", absl::PairFormatter("->")) + "\nGenerators found:\n " + absl::StrJoin(permutations_str, "\n ")); // Verify the equivalence classes. EXPECT_EQ(expected_node_equivalence_classes, DynamicPartition(node_equivalence_classes) .DebugString(/*sort_parts_lexicographically=*/true)); // Verify the automorphism group size. double log_of_permutation_group_size = 0.0; for (const int orbit_size : orbit_sizes) { log_of_permutation_group_size += log(orbit_size); } EXPECT_THAT(log_of_permutation_group_size, DoubleEq(log_of_expected_permutation_group_size)) << absl::StrJoin(orbit_sizes, " x "); if (log_of_expected_permutation_group_size <= log(1000.0)) { const int expected_permutation_group_size = static_cast(round(exp(log_of_expected_permutation_group_size))); EXPECT_EQ( expected_permutation_group_size, ComputePermutationGroupSizeAndVerifyBasicIrreductibility(generators)); } } }; TEST_F(FindSymmetriesTest, CyclesOfDifferentLength) { // The same test case as before, but this time we do expect the symmetry // detector to figure out that the two cycles of different lengths aren't // symmetric. ExpectSymmetries({{1, 2}, {2, 3}, {3, 1}, {4, 5}, {5, 4}}, "0 | 1 2 3 | 4 5", log(6)); } // This can be used to convert a list of M undirected edges into the list of // 2*M corresponding directed arcs. std::vector> AppendReversedPairs( absl::Span> pairs) { std::vector> out; out.reserve(pairs.size() * 2); out.insert(out.begin(), pairs.begin(), pairs.end()); for (const auto [from, to] : pairs) out.push_back({to, from}); return out; } // See: http://en.wikipedia.org/wiki/Petersen_graph, where it looks a lot // more symmetric than the ASCII art below. // // .---------5---------. // / | \ // / 0 \ // 9--------4--/-\--1--------6 // \ \/ \/ / // \ /\ /\ / // \ / `.ˊ \ / // \ 3---ˊ `---2 / // \ / \ / // 8-------------7 std::vector> PetersenGraphEdges() { return { {0, 2}, {1, 3}, {2, 4}, {3, 0}, {4, 1}, // Inner star {5, 6}, {6, 7}, {7, 8}, {8, 9}, {9, 5}, // Outer pentagon {0, 5}, {1, 6}, {2, 7}, {3, 8}, {4, 9}, // Star <-> Pentagon }; } TEST_F(FindSymmetriesTest, PetersenGraph) { ExpectSymmetries(AppendReversedPairs(PetersenGraphEdges()), "0 1 2 3 4 5 6 7 8 9", log(120)); } TEST_F(FindSymmetriesTest, UndirectedCyclesOfDifferentLength) { // 0---1 3--4 // \ / | | // 2 6--5 ExpectSymmetries( { {0, 1}, {1, 2}, {2, 0}, // Triangle, CW. {2, 1}, {1, 0}, {0, 2}, // Triangle, CCW. {3, 4}, {4, 5}, {5, 6}, {6, 3}, // Square, CW. {6, 5}, {5, 4}, {4, 3}, {3, 6}, // Square, CCW. }, "0 1 2 | 3 4 5 6", log(48)); } TEST_F(FindSymmetriesTest, SmallestCyclicGroupUndirectedGraph) { // See http://mathworld.wolfram.com/GraphAutomorphism.html. // // 2 // / \ // 7---0---1 // / \ / \ / // 8---6---3 // \ / \ // 4---5 ExpectSymmetries( { {0, 3}, {3, 0}, {3, 6}, {6, 3}, {6, 0}, {0, 6}, // Inner triangle 0-3-6. {0, 1}, {1, 0}, {3, 1}, {1, 3}, // Angle 0-1-3. {3, 4}, {4, 3}, {6, 4}, {4, 6}, // Angle 3-4-6. {6, 7}, {7, 6}, {0, 7}, {7, 0}, // Angle 6-7-0. {0, 2}, {2, 0}, {2, 1}, {1, 2}, // Angle 0-2-1. {3, 5}, {5, 3}, {5, 4}, {4, 5}, // Angle 3-5-4. {6, 8}, {8, 6}, {8, 7}, {7, 8}, // Angle 6-8-7. }, "0 3 6 | 1 4 7 | 2 5 8", log(3)); } double LogFactorial(int n) { double sum = 0.0; for (int i = 1; i <= n; ++i) sum += log(i); return sum; } TEST_F(FindSymmetriesTest, Clique) { // Note(user): as of 2014-01-22, the symetry finder is extremely inefficient // on this test for size = 6 (7s in fastbuild), while it takes only a // fraction of that time for larger sizes. // TODO(user): fix this inefficiency and enlarge the test space. const int kMaxSize = DEBUG_MODE ? 5 : 120; std::vector> arcs; std::vector nodes; for (int size = 1; size <= kMaxSize; ++size) { SCOPED_TRACE(absl::StrFormat("Size: %d", size)); const int new_node = size - 1; nodes.push_back(new_node); for (int old_node = 0; old_node < new_node; ++old_node) { arcs.push_back({old_node, new_node}); arcs.push_back({new_node, old_node}); } // When size = 1; the graph looks empty to ExpectSymmetries() because there // are no arcs. Skip to n >= 2. if (size == 1) continue; ExpectSymmetries(arcs, absl::StrJoin(nodes, " "), LogFactorial(size)); if (HasFailure()) break; // Don't spam the user with more than one failure. } } TEST_F(FindSymmetriesTest, DirectedStar) { // Note(user): as of 2014-01-22, the symetry finder is extremely inefficient // on this test for size = 6 (and relatively too, for size = 5): it takes only // a fraction of time for larger sizes, but about 16s in fastbuild mode for 6. // TODO(user): fix this inefficiency and enlarge the test space. // // Example for size = 4, with outward arcs: // // 1 // ^ // | // 4<----0---->2 // | // v // 3 const int kMaxSize = DEBUG_MODE ? 5 : 120; std::vector> out_arcs; std::vector> in_arcs; std::string expected_equivalence_classes = "0 |"; for (int size = 1; size <= kMaxSize; ++size) { SCOPED_TRACE(absl::StrFormat("Size: %d", size)); absl::StrAppend(&expected_equivalence_classes, " ", size); out_arcs.push_back({0, size}); in_arcs.push_back({size, 0}); // When size = 1; the formula below doesn't work. Skip to n >= 2. if (size == 1) continue; ExpectSymmetries(out_arcs, expected_equivalence_classes, LogFactorial(size)); ExpectSymmetries(in_arcs, expected_equivalence_classes, LogFactorial(size)); if (HasFailure()) break; // Don't spam the user with more than one failure. } } TEST_F(FindSymmetriesTest, UndirectedAntiPrism) { // See http://mathworld.wolfram.com/GraphAutomorphism.html . // Example for size = 8: // // .-0---1-. // .ˊ / `ₓˊ \ `. // 7--/--ˊ `--\--2 // |\/ \/| // |/\ /\| // 6--\--. .--/--3 // `. \ .ˣ. / .ˊ // `-5---4-ˊ const int kMaxSize = DEBUG_MODE ? 60 : 150; std::vector nodes; std::vector> arcs; for (int size = 6; size <= kMaxSize; size += 2) { SCOPED_TRACE(absl::StrFormat("Size: %d", size)); arcs.clear(); nodes.clear(); for (int i = 0; i < size; ++i) { nodes.push_back(i); const int next = (i + 1) % size; const int next2 = (i + 2) % size; arcs.push_back({i, next}); arcs.push_back({i, next2}); arcs.push_back({next, i}); arcs.push_back({next2, i}); } ExpectSymmetries(arcs, absl::StrJoin(nodes, " "), log(size == 6 ? 48 : 2 * size)); if (HasFailure()) break; // Don't spam the user with more than one failure. } } TEST_F(FindSymmetriesTest, UndirectedHypercube) { // Example for dimension = 3 (the numbering fits the standard construction, // where vertices X and Y have an edge iff they differ by exactly one bit). // // 0-----1 // |\ |\ // | 4-----5 // | | | | // 2-|---3 | // \| \| // 7-----8 // // See http://mathworld.wolfram.com/GraphAutomorphism.html : the expected size // of the automorphism group is (2 * 4 * 6 * ... * (2 * dimension)). const int kMaxDimension = DEBUG_MODE ? 7 : 15; for (int dimension = 1; dimension <= kMaxDimension; ++dimension) { SCOPED_TRACE(absl::StrFormat("Dimension: %d", dimension)); const int num_nodes = 1 << dimension; std::vector nodes(num_nodes, -1); for (int i = 0; i < num_nodes; ++i) nodes[i] = i; const int num_arcs = num_nodes * dimension; std::vector> arcs; arcs.reserve(num_arcs); for (int from = 0; from < num_nodes; ++from) { for (int bit_order = 0; bit_order < dimension; ++bit_order) { arcs.push_back({from, from ^ (1 << bit_order)}); } } double log_of_expected_group_size = 0.0; for (int i = 1; i <= dimension; ++i) { log_of_expected_group_size += log(2 * i); } ExpectSymmetries(arcs, absl::StrJoin(nodes, " "), log_of_expected_group_size); if (HasFailure()) break; // Don't spam the user with more than one failure. } } TEST_F(FindSymmetriesTest, DirectedHypercube) { // Just like UndirectedHypercube, but arcs are always oriented from lower // hamming weight to higher hamming weight. // The symmetries are all permutations of the bits of the node indices. // // Note(user): As of 2014-01-22, dimension=6 exhibits the same peculiar slow // behavior (much slower than larger or smaller dimensions). // // TODO(user): Increase kMaxDimension to at least 15 in opt mode, when the // performance gets restored to the state before CL 66308548. const int kMaxDimension = DEBUG_MODE ? 5 : 14; for (int dimension = 1; dimension <= kMaxDimension; ++dimension) { SCOPED_TRACE(absl::StrFormat("Dimension: %d", dimension)); const int num_nodes = 1 << dimension; const int num_arcs = num_nodes * dimension; std::vector> arcs; arcs.reserve(num_arcs); for (int from = 0; from < num_nodes; ++from) { for (int bit_order = 0; bit_order < dimension; ++bit_order) { const int to = from ^ (1 << bit_order); if (to > from) arcs.push_back({from, to}); } } // The equivalence classes are the nodes with the same hamming weight. std::vector> nodes_by_hamming_weight(dimension + 1); for (int i = 0; i < num_nodes; ++i) { nodes_by_hamming_weight[absl::popcount(unsigned(i))].push_back(i); } std::vector expected_equivalence_classes; for (const std::vector& nodes : nodes_by_hamming_weight) { expected_equivalence_classes.push_back(absl::StrJoin(nodes, " ")); } ExpectSymmetries(arcs, absl::StrJoin(expected_equivalence_classes, " | "), LogFactorial(dimension)); if (HasFailure()) break; // Don't spam the user with more than one failure. } } TEST_F(FindSymmetriesTest, InwardGrid) { // Directed NxN grids where all arcs are towards the center (if N is even, // the arcs between the two middle rows (or columns) are bidirectional). // Example for N=3 and N=4: // // 0 → 1 ← 2 0 → 1 ↔ 2 ← 3 // ↓ ↓ ↓ ↓ ↓ ↓ ↓ // 3 → 4 ← 5 4 → 5 ↔ 6 ← 7 // ↑ ↑ ↑ ↕ ↕ ↕ ↕ // 6 → 7 ← 8 8 → 9 ↔ 10← 11 // ↑ ↑ ↑ ↑ // 12→ 13↔ 14← 15 // // Note(user): this test proved very useful: it exercises the code path where // we find a perfect permutation match that is not an automorphism, and it // also uncovered the suspected flaw of the code as of CL 59849337 (overly // aggressive pruning). const int kMaxSize = DEBUG_MODE ? 30 : 100; std::vector> arcs; for (int size = 2; size <= kMaxSize; ++size) { SCOPED_TRACE(absl::StrFormat("Size: %d", size)); arcs.clear(); for (int i = 0; i < size / 2; ++i) { const int sym_i = size - 1 - i; for (int j = 0; j < size; ++j) { arcs.push_back({i * size + j, (i + 1) * size + j}); // Down arcs.push_back({sym_i * size + j, (sym_i - 1) * size + j}); // Up arcs.push_back({j * size + i, j * size + i + 1}); // Right arcs.push_back({j * size + sym_i, j * size + sym_i - 1}); // Left } } // Build the expected equivalence classes. std::vector expected_equivalence_classes; for (int i = 0; i <= (size - 1) / 2; ++i) { const int sym_i = size - 1 - i; for (int j = i; j <= (size - 1) / 2; ++j) { const int sym_j = size - 1 - j; std::vector symmetric_nodes = { i * size + j, j * size + i, sym_i * size + j, j * size + sym_i, i * size + sym_j, sym_j * size + i, sym_i * size + sym_j, sym_j * size + sym_i, }; std::set unique_nodes(symmetric_nodes.begin(), symmetric_nodes.end()); expected_equivalence_classes.push_back( absl::StrJoin(unique_nodes, " ")); } } ExpectSymmetries(arcs, absl::StrJoin(expected_equivalence_classes, " | "), log(8)); if (HasFailure()) break; // Don't spam the user with more than one failure. } } void AddReverseArcs(Graph* graph) { const int num_arcs = graph->num_arcs(); for (int a = 0; a < num_arcs; ++a) { graph->AddArc(graph->Head(a), graph->Tail(a)); } } void AddReverseArcsAndFinalize(Graph* graph) { AddReverseArcs(graph); graph->Build(); } void SetGraphEdges(absl::Span> edges, Graph* graph) { DCHECK_EQ(graph->num_arcs(), 0); for (const auto [from, to] : edges) graph->AddArc(from, to); AddReverseArcsAndFinalize(graph); } TEST(CountTrianglesTest, EmptyGraph) { EXPECT_THAT(CountTriangles(Graph(0, 0), /*max_degree=*/0), IsEmpty()); EXPECT_THAT(CountTriangles(Graph(0, 0), /*max_degree=*/9999), IsEmpty()); } TEST(CountTrianglesTest, SimpleUndirectedExample) { // 0--1--2 // `.|`.| // 3--4--5 Graph g; SetGraphEdges( {{0, 1}, {1, 2}, {0, 3}, {1, 4}, {1, 3}, {2, 4}, {3, 4}, {4, 5}}, &g); // Reminder: every undirected triangle counts as two directed triangles. EXPECT_THAT(CountTriangles(g, /*max_degree=*/999), ElementsAre(2, 6, 2, 4, 4, 0)); EXPECT_THAT(CountTriangles(g, /*max_degree=*/3), ElementsAre(2, 0, 2, 4, 0, 0)); EXPECT_THAT(CountTriangles(g, /*max_degree=*/2), ElementsAre(2, 0, 2, 0, 0, 0)); EXPECT_THAT(CountTriangles(g, /*max_degree=*/1), ElementsAre(0, 0, 0, 0, 0, 0)); EXPECT_THAT(CountTriangles(g, /*max_degree=*/0), ElementsAre(0, 0, 0, 0, 0, 0)); } TEST(CountTrianglesTest, SimpleDirectedExample) { // .-> 1 -> 2 <-. // / ^ ^ \ // 0 | | 5 // \ | v / // `-> 3 <- 4 <-' Graph g; for (auto [from, to] : std::vector>{ {0, 1}, {1, 2}, {0, 3}, {4, 3}, {5, 2}, {5, 4}, {3, 1}, {2, 4}, {4, 2}, }) { g.AddArc(from, to); } g.Build(); EXPECT_THAT(CountTriangles(g, /*max_degree=*/999), ElementsAre(1, 0, 0, 0, 0, 2)); EXPECT_THAT(CountTriangles(g, /*max_degree=*/1), ElementsAre(0, 0, 0, 0, 0, 0)); } TEST(LocalBfsTest, SimpleExample) { // 0--1--2 // `.|`.| // 3--4--5 Graph g; SetGraphEdges( {{0, 1}, {1, 2}, {0, 3}, {1, 4}, {1, 3}, {2, 4}, {3, 4}, {4, 5}}, &g); std::vector tmp_mask(g.num_nodes(), false); std::vector visited; std::vector num_within_radius; // Run a first unlimited BFS from 0. LocalBfs(g, /*source=*/0, /*stop_after_num_nodes=*/99, &visited, &num_within_radius, &tmp_mask); EXPECT_THAT( visited, // Nodes should be sorted by distance. (1,3) and (2,4) have the // same, so we have 4 possible orders. Though if 3 was settled // first, then 4 must be before 2, since 3 is only connected to 4. AnyOf(ElementsAre(0, 1, 3, 2, 4, 5), ElementsAre(0, 1, 3, 4, 2, 5), ElementsAre(0, 3, 1, 4, 2, 5))); EXPECT_THAT(num_within_radius, ElementsAre(1, 3, 5, 6)); // Then a BFS that stops after visiting 4 nodes: we should finish exploring // that distance, i.e. explore 2 and 4, but not 5. Still, 5 is "visited". LocalBfs(g, /*source=*/0, /*stop_after_num_nodes=*/4, &visited, &num_within_radius, &tmp_mask); EXPECT_THAT(visited, AnyOf(ElementsAre(0, 1, 3, 2, 4, 5), ElementsAre(0, 1, 3, 4, 2, 5), ElementsAre(0, 3, 1, 4, 2, 5))); EXPECT_THAT(num_within_radius, ElementsAre(1, 3, 5, 6)); // Then a BFS that stops after visiting 2 nodes. LocalBfs(g, /*source=*/0, /*stop_after_num_nodes=*/2, &visited, &num_within_radius, &tmp_mask); EXPECT_THAT(visited, AnyOf(ElementsAre(0, 1, 3, 2, 4), ElementsAre(0, 1, 3, 4, 2), ElementsAre(0, 3, 1, 4, 2))); EXPECT_THAT(num_within_radius, ElementsAre(1, 3, 5)); // Now run a BFS from node 3, stop exploring after 1 node. LocalBfs(g, /*source=*/3, /*stop_after_num_nodes=*/1, &visited, &num_within_radius, &tmp_mask); EXPECT_THAT(visited, UnorderedElementsAre(3, 0, 1, 4)); EXPECT_THAT(num_within_radius, ElementsAre(1, 4)); // Now after 2 nodes. LocalBfs(g, /*source=*/3, /*stop_after_num_nodes=*/2, &visited, &num_within_radius, &tmp_mask); EXPECT_THAT(visited, UnorderedElementsAre(3, 0, 1, 4, 2, 5)); EXPECT_THAT(num_within_radius, ElementsAre(1, 4, 6)); } } // namespace } // namespace operations_research