Files
ortools-clone/ortools/algorithms/find_graph_symmetries_test.cc
Corentin Le Molgat a66a6daac7 Bump Copyright to 2025
2025-01-10 11:35:44 +01:00

812 lines
31 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 <algorithm>
#include <cinttypes>
#include <cmath>
#include <cstdint>
#include <map>
#include <memory>
#include <numeric>
#include <random>
#include <set>
#include <string>
#include <utility>
#include <vector>
#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<const std::pair<int, int>> arcs) {
Graph graph;
for (const std::pair<int, int>& 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<int> node_equivalence_classes_io;
std::vector<std::unique_ptr<SparsePermutation>> generators;
std::vector<int> 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<std::pair<int, int>>& graph_arcs,
absl::Span<const std::vector<int>> permutation_cycles,
bool expected_is_automorphism) {
Graph graph(num_nodes, graph_arcs.size());
for (const std::pair<int, int>& 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<int>& cycle : permutation_cycles) {
std::vector<int> 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<std::pair<int, int>> 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<std::pair<int, int>> 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<std::pair<int, int>> 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<int> GetDensePermutation(const SparsePermutation& permutation) {
std::vector<int> 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<int> ComposePermutations(absl::Span<const int> p1,
absl::Span<const int> p2) {
CHECK_EQ(p1.size(), p2.size());
std::vector<int> 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<const std::unique_ptr<SparsePermutation>> 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<std::vector<int>> 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<int> 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<const std::vector<int>*> new_perms(
1, &(*insertion_result.first));
while (!new_perms.empty()) {
const std::vector<int>* new_perm = new_perms.back();
new_perms.pop_back();
std::vector<const std::vector<int>*> old_perms;
old_perms.reserve(permutation_group.size());
for (const std::vector<int>& p : permutation_group)
old_perms.push_back(&p);
for (const std::vector<int>* 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<std::pair<int, int>>& arcs,
absl::string_view expected_node_equivalence_classes,
double log_of_expected_permutation_group_size) {
Graph graph;
for (const std::pair<int, int>& arc : arcs)
graph.AddArc(arc.first, arc.second);
graph.Build();
GraphSymmetryFinder symmetry_finder(graph, GraphIsSymmetric(graph));
std::vector<std::unique_ptr<SparsePermutation>> generators;
std::vector<int> node_equivalence_classes(graph.num_nodes(), 0);
std::vector<int> orbit_sizes;
TimeLimit time_limit(kDefaultTimeLimitSeconds);
ASSERT_OK(symmetry_finder.FindSymmetries(
&node_equivalence_classes, &generators, &orbit_sizes, &time_limit));
std::vector<std::string> permutations_str;
for (const std::unique_ptr<SparsePermutation>& 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<int>(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<std::pair<int, int>> AppendReversedPairs(
absl::Span<const std::pair<int, int>> pairs) {
std::vector<std::pair<int, int>> 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<std::pair<int, int>> 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<std::pair<int, int>> arcs;
std::vector<int> 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<std::pair<int, int>> out_arcs;
std::vector<std::pair<int, int>> 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<int> nodes;
std::vector<std::pair<int, int>> 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<int> nodes(num_nodes, -1);
for (int i = 0; i < num_nodes; ++i) nodes[i] = i;
const int num_arcs = num_nodes * dimension;
std::vector<std::pair<int, int>> 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<std::pair<int, int>> 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<std::vector<int>> 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<std::string> expected_equivalence_classes;
for (const std::vector<int>& 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<std::pair<int, int>> 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<std::string> 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<int> 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<int> 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<const std::pair<int, int>> 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<std::pair<int, int>>{
{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<bool> tmp_mask(g.num_nodes(), false);
std::vector<int> visited;
std::vector<int> 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