From dfe5d816779cf05f6c304ec5ed3ef095ee5273ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 11:56:21 +0100 Subject: [PATCH 01/15] =?UTF-8?q?test(cpp):=20=E2=9C=85=20register=20every?= =?UTF-8?q?=20case=20and=20run=20the=20MPI=20suite=20with=20splitmix=20rou?= =?UTF-8?q?ting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- cpp/tests/boost-test.cmake | 10 ++ cpp/tests/boostAddTests.cmake | 168 +++++++++++++++++++++------------- 2 files changed, 113 insertions(+), 65 deletions(-) diff --git a/cpp/tests/boost-test.cmake b/cpp/tests/boost-test.cmake index f1d4c1a5a..a6e53d7ad 100644 --- a/cpp/tests/boost-test.cmake +++ b/cpp/tests/boost-test.cmake @@ -7,6 +7,15 @@ set( CACHE STRING "Semicolon-separated list of ranks for MPI test variants" ) + +# Linear routing, the default, refuses a rank count that is not a power of two, so 3 ranks run +# only here; 2 and 4 rerun the dense all-to-all at the counts linear routing also covers. +set( + monoprop_MPI_SPLITMIX_TEST_PROCS + "2;3;4" + CACHE STRING + "Semicolon-separated list of ranks for the MPI test variants run with monoprop_ROUTING=splitmix" +) set(_monoprop_mpiexec "${MPIEXEC_EXECUTABLE}") if(NOT _monoprop_mpiexec) find_program( @@ -73,6 +82,7 @@ function(discover_tests TARGET) "TEST_LIST=${_TEST_LIST}" -D "CTEST_FILE=${ctest_tests_file}" -D "TEST_ENABLE_MPI_VARIANTS=${_enable_mpi_variants}" -D "TEST_MPI_NUMPROCS=${monoprop_MPI_TEST_PROCS}" -D + "TEST_MPI_SPLITMIX_NUMPROCS=${monoprop_MPI_SPLITMIX_TEST_PROCS}" -D "MPIEXEC_EXECUTABLE=${_monoprop_mpiexec}" -D "MPIEXEC_NUMPROC_FLAG=${_monoprop_mpiexec_numproc_flag}" -D "MPIEXEC_PREFLAGS=${MPIEXEC_PREFLAGS}" -D diff --git a/cpp/tests/boostAddTests.cmake b/cpp/tests/boostAddTests.cmake index 32834ca29..c60ab3749 100644 --- a/cpp/tests/boostAddTests.cmake +++ b/cpp/tests/boostAddTests.cmake @@ -18,23 +18,29 @@ if(TEST_ENABLE_MPI_VARIANTS AND NOT MPIEXEC_NUMPROC_FLAG) ) endif() -set(_mpi_ranks) -if(TEST_ENABLE_MPI_VARIANTS) - if("${TEST_MPI_NUMPROCS}" STREQUAL "") - set(TEST_MPI_NUMPROCS 2) - endif() - - foreach(_rank IN LISTS TEST_MPI_NUMPROCS) +# Positive integers, deduplicated; an empty list is legal and registers nothing. +function(validated_ranks OUT LIST_NAME) + set(_ranks ${${LIST_NAME}}) + foreach(_rank IN LISTS _ranks) if(NOT _rank MATCHES "^[1-9][0-9]*$") message( FATAL_ERROR - "Invalid MPI rank '${_rank}' in TEST_MPI_NUMPROCS='${TEST_MPI_NUMPROCS}'. Use positive integers." + "Invalid MPI rank '${_rank}' in ${LIST_NAME}='${${LIST_NAME}}'. Use positive integers." ) endif() endforeach() + list(REMOVE_DUPLICATES _ranks) + set(${OUT} ${_ranks} PARENT_SCOPE) +endfunction() - set(_mpi_ranks ${TEST_MPI_NUMPROCS}) - list(REMOVE_DUPLICATES _mpi_ranks) +set(_mpi_ranks) +set(_mpi_splitmix_ranks) +if(TEST_ENABLE_MPI_VARIANTS) + if("${TEST_MPI_NUMPROCS}" STREQUAL "") + set(TEST_MPI_NUMPROCS 2) + endif() + validated_ranks(_mpi_ranks TEST_MPI_NUMPROCS) + validated_ranks(_mpi_splitmix_ranks TEST_MPI_SPLITMIX_NUMPROCS) endif() set(extra_args ${TEST_EXTRA_ARGS}) @@ -192,73 +198,105 @@ string( "${output}" ) -# process each line foreach(LINE ${LINES}) - # Remove trailing asterisk + # A data-driven case lists its children (_0, _1, ...) indented beneath it, and running the parent + # runs them all. Matching the name instead would also drop a case like `..._32_bit`. + if(LINE MATCHES "^[ \t]") + continue() + endif() string( REGEX REPLACE "\\*$" "" - CLEANED_LINE + test "${LINE}" ) - - # Trim whitespace - string(STRIP "${CLEANED_LINE}" test) - - # Check if the line doesn't contain _0, _1, etc. and is not empty - if(NOT test MATCHES "_[0-9]+" AND NOT "${test}" STREQUAL "") - register_variant("${test}" - COMMAND - "${TEST_EXECUTABLE}" - "--run_test=${test}" - "--report_level=detailed" - "--catch_system_errors=yes" - ${extra_args} - LABELS - serial - ENVIRONMENT - ${serial_env} - ) + string(STRIP "${test}" test) + if(test STREQUAL "") + continue() endif() -endforeach() - -# The MPI variants wrap the WHOLE suite in one mpiexec per rank count: the ranks must reach the -# same collectives, which per-case launches cannot guarantee. -if(TEST_ENABLE_MPI_VARIANTS AND MPIEXEC_EXECUTABLE) - foreach(_mpi_rank IN LISTS _mpi_ranks) - set(mpi_cmd "${MPIEXEC_EXECUTABLE}") - list( - APPEND mpi_cmd - "${MPIEXEC_NUMPROC_FLAG}" - "${_mpi_rank}" - ) - if(MPIEXEC_PREFLAGS) - list(APPEND mpi_cmd ${MPIEXEC_PREFLAGS}) - endif() - list( - APPEND mpi_cmd + # Boost reports a case whose precondition failed (e.g. too few ranks) as skipped with exit 0; + # without this CTest would count it as a pass. + register_variant("${test}" + COMMAND "${TEST_EXECUTABLE}" + "--run_test=${test}" "--report_level=detailed" "--catch_system_errors=yes" ${extra_args} - ) - if(MPIEXEC_POSTFLAGS) - list(APPEND mpi_cmd ${MPIEXEC_POSTFLAGS}) - endif() + LABELS + serial + ENVIRONMENT + ${serial_env} + PROPERTIES + SKIP_REGULAR_EXPRESSION + "Test case \"[^\"]+\" was skipped" + ) +endforeach() - register_variant("${TEST_TARGET}_mpi_${_mpi_rank}" - COMMAND - ${mpi_cmd} - LABELS - mpi - "mpi-${_mpi_rank}" - ENVIRONMENT - "OMPI_ALLOW_RUN_AS_ROOT=1" - "OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1" - PROPERTIES - TIMEOUT - 600 - ) +# The MPI variants wrap the WHOLE suite in one mpiexec per rank count: the ranks must reach the +# same collectives, which per-case launches cannot guarantee. No skip regex here: a case may skip at +# one rank count (e.g. it needs 4) while the rest of the suite runs. +# +# Open MPI refuses more ranks than slots, and 4 ranks exceed a 3-core runner; both spellings +# cover ORTE (4.x) and PRRTE (5.x), and other MPIs ignore them. +set( + _mpi_env + "OMPI_ALLOW_RUN_AS_ROOT=1" + "OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1" + "OMPI_MCA_rmaps_base_oversubscribe=1" + "PRTE_MCA_rmaps_default_mapping_policy=:oversubscribe" +) + +function(register_mpi_variant RANKS ROUTING) + set( + mpi_cmd + "${MPIEXEC_EXECUTABLE}" + "${MPIEXEC_NUMPROC_FLAG}" + "${RANKS}" + ) + if(MPIEXEC_PREFLAGS) + list(APPEND mpi_cmd ${MPIEXEC_PREFLAGS}) + endif() + list( + APPEND mpi_cmd + "${TEST_EXECUTABLE}" + "--report_level=detailed" + "--catch_system_errors=yes" + ${extra_args} + ) + if(MPIEXEC_POSTFLAGS) + list(APPEND mpi_cmd ${MPIEXEC_POSTFLAGS}) + endif() + + set(_name "${TEST_TARGET}_mpi_${RANKS}") + set(_env ${_mpi_env}) + if(ROUTING STREQUAL "splitmix") + string(APPEND _name "_splitmix") + list(APPEND _env "monoprop_ROUTING=splitmix") + endif() + register_variant("${_name}" + COMMAND + ${mpi_cmd} + LABELS + mpi + "mpi-${RANKS}" + "mpi-${ROUTING}" + ENVIRONMENT + ${_env} + PROPERTIES + TIMEOUT + 600 + ) + set(script "${script}" PARENT_SCOPE) + set(tests ${tests} PARENT_SCOPE) +endfunction() + +if(TEST_ENABLE_MPI_VARIANTS AND MPIEXEC_EXECUTABLE) + foreach(_mpi_rank IN LISTS _mpi_ranks) + register_mpi_variant("${_mpi_rank}" linear) + endforeach() + foreach(_mpi_rank IN LISTS _mpi_splitmix_ranks) + register_mpi_variant("${_mpi_rank}" splitmix) endforeach() endif() From b1dcfdef860e27b0768d0fba995c353bc4c56653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 12:16:39 +0100 Subject: [PATCH 02/15] =?UTF-8?q?test(cpp):=20=E2=9C=85=20skip=20cases=20t?= =?UTF-8?q?hat=20need=20several=20ranks=20instead=20of=20passing=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- cpp/tests/RankPreconditions.h | 89 ++++++++++++++ cpp/tests/exact_upper_atol_rescue.cpp | 29 +++-- cpp/tests/flat_exchange_tests.cpp | 21 +--- cpp/tests/hybrid_comm_tests.cpp | 112 ++++-------------- .../mpi_distributed_layer_equivalence.cpp | 25 +--- cpp/tests/mpi_fresh_insert_equivalence.cpp | 15 +-- cpp/tests/mpi_pare.cpp | 7 +- cpp/tests/routing_tests.cpp | 11 +- 8 files changed, 147 insertions(+), 162 deletions(-) create mode 100644 cpp/tests/RankPreconditions.h diff --git a/cpp/tests/RankPreconditions.h b/cpp/tests/RankPreconditions.h new file mode 100644 index 000000000..ac8735ed1 --- /dev/null +++ b/cpp/tests/RankPreconditions.h @@ -0,0 +1,89 @@ +// Copyright 2026 Algorithmiq +// +// 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. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "monoprop/detail/mpi/MPICompat.h" +#include "monoprop/detail/mpi/Routing.h" + +/* + * Boost.Test preconditions for cases that need a particular MPI world. A case whose precondition + * fails is reported as skipped, with the reason, instead of passing without checking anything; + * CTest turns that report into a skip for the per-case serial entries. + */ +namespace test_utils { + +namespace rank_detail { + +inline auto world_size_precondition(std::function accepts, std::string need) + -> boost::unit_test::decorator::precondition { + return boost::unit_test::precondition( + [accepts = std::move(accepts), need = std::move(need)](boost::unit_test::test_unit_id) { + const int ranks = monoprop::mpi::size(MPI_COMM_WORLD); + boost::test_tools::assertion_result result(accepts(ranks)); + result.message() << "needs " << need << ", MPI_COMM_WORLD has " << ranks; + return result; + }); +} + +} // namespace rank_detail + +/// Requires at least @p count ranks in MPI_COMM_WORLD. +inline auto min_ranks(int count) -> boost::unit_test::decorator::precondition { + return rank_detail::world_size_precondition([count](int ranks) { return ranks >= count; }, + std::format("at least {} ranks", count)); +} + +/// Requires a power-of-two rank count of at least @p count, which the XOR pairing needs. +inline auto power_of_two_ranks(int count = 2) -> boost::unit_test::decorator::precondition { + return rank_detail::world_size_precondition( + [count](int ranks) { return ranks >= count && std::has_single_bit(static_cast(ranks)); }, + std::format("a power-of-two rank count of at least {}", count)); +} + +/// Requires an even rank count, so that `rank ^ 1` pairs every rank. +inline auto even_ranks() -> boost::unit_test::decorator::precondition { + return rank_detail::world_size_precondition([](int ranks) { return ranks >= 2 && ranks % 2 == 0; }, + "an even rank count"); +} + +/// Requires monoprop_ROUTING to resolve to linear routing. +inline auto linear_routing() -> boost::unit_test::decorator::precondition { + return boost::unit_test::precondition([](boost::unit_test::test_unit_id) { + boost::test_tools::assertion_result result(monoprop::routing::linear_requested()); + result.message() << "needs linear routing, but monoprop_ROUTING selects splitmix"; + return result; + }); +} + +/// Requires monoprop_ROUTING to be unset, so that the shipped default is what runs. +inline auto default_routing() -> boost::unit_test::decorator::precondition { + return boost::unit_test::precondition([](boost::unit_test::test_unit_id) { + const char *mode = std::getenv("monoprop_ROUTING"); + boost::test_tools::assertion_result result(mode == nullptr || *mode == '\0'); + result.message() << "needs monoprop_ROUTING unset, but it is set"; + return result; + }); +} + +} // namespace test_utils diff --git a/cpp/tests/exact_upper_atol_rescue.cpp b/cpp/tests/exact_upper_atol_rescue.cpp index 3243d965d..9c48464b0 100644 --- a/cpp/tests/exact_upper_atol_rescue.cpp +++ b/cpp/tests/exact_upper_atol_rescue.cpp @@ -18,6 +18,7 @@ #include #include +#include "RankPreconditions.h" #include "TestUtilities.h" // upper_atol rescue: a structural cutoff of 0 rejects every non-identity partner, and upper_atol = 0 @@ -32,8 +33,6 @@ using namespace monoprop; constexpr double kEnergyAtol = 1e-9; -enum class CommMode { Self, World }; - // build_simulator cannot express this: it hardcodes cutoff = 2*NumModes. template auto build_zero_cutoff_full_rescue(const CaseData& data, MPI_Comm comm) -> MonomialPropagator { @@ -58,22 +57,22 @@ auto evaluate_zero_cutoff_full_rescue_energy(MonomialPropagator& simul } // namespace // One test per (fixture, comm) so a failure pinpoints the configuration. -#define MAKE_ZERO_CUTOFF_RESCUE_TEST(NAME, FixtureType, CommToken) \ - BOOST_FIXTURE_TEST_CASE(NAME##_##CommToken, FixtureType) { \ - MPI_Comm comm = (CommMode::CommToken == CommMode::Self) ? MPI_COMM_SELF : MPI_COMM_WORLD; \ - if (CommMode::CommToken == CommMode::World && mpi::size(comm) == 1) { \ - BOOST_TEST_MESSAGE("Skipping multi-rank scenario for " #NAME " (world size=1)"); \ - return; \ - } \ - auto simulator = build_zero_cutoff_full_rescue(data, comm); \ +#define MAKE_ZERO_CUTOFF_RESCUE_TEST(NAME, FixtureType, COMM, ...) \ + BOOST_FIXTURE_TEST_CASE(NAME, FixtureType __VA_OPT__(, ) __VA_ARGS__) { \ + auto simulator = build_zero_cutoff_full_rescue(data, COMM); \ const double energy = evaluate_zero_cutoff_full_rescue_energy(simulator, data); \ BOOST_CHECK_SMALL(std::abs(energy - data.actual_expval), kEnergyAtol); \ } -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact, ExampleDataFix, Self) -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact, ExampleDataFix, World) - -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih, LihFixture, Self) -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih, LihFixture, World) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_Self, ExampleDataFix, MPI_COMM_SELF) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_World, + ExampleDataFix, + MPI_COMM_WORLD, + *test_utils::min_ranks(2)) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih_Self, LihFixture, MPI_COMM_SELF) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih_World, + LihFixture, + MPI_COMM_WORLD, + *test_utils::min_ranks(2)) #undef MAKE_ZERO_CUTOFF_RESCUE_TEST diff --git a/cpp/tests/flat_exchange_tests.cpp b/cpp/tests/flat_exchange_tests.cpp index c50083231..7d0608724 100644 --- a/cpp/tests/flat_exchange_tests.cpp +++ b/cpp/tests/flat_exchange_tests.cpp @@ -24,6 +24,7 @@ #include #include +#include "RankPreconditions.h" #include "monoprop/detail/mpi/Comm.h" #include "monoprop/detail/mpi/Exchange.h" @@ -118,11 +119,8 @@ auto run_round(int n, // A dense gate is today's collective whatever the layout holds -- including the sparse layout the // pairwise arm exists for. The layout must not be able to move the branch. -BOOST_AUTO_TEST_CASE(flat_exchange_dense_gate_always_takes_the_collective) { +BOOST_AUTO_TEST_CASE(flat_exchange_dense_gate_always_takes_the_collective, *test_utils::even_ranks()) { const int n = world_size(); - if (n < 2 || (n % 2) != 0) { - return; - } const int me = world_rank(); const int peer = me ^ 1; for (const bool dense : {true, false}) { @@ -147,11 +145,8 @@ BOOST_AUTO_TEST_CASE(flat_exchange_dense_gate_always_takes_the_collective) { // One peer, me ^ 1 -- an involution, so the count matrix stays symmetric and both ends drop the same // n - 1 legs. Two requests (one Irecv, one Isend) and the same bytes delivered. -BOOST_AUTO_TEST_CASE(flat_exchange_single_leg_takes_the_pairwise_path) { +BOOST_AUTO_TEST_CASE(flat_exchange_single_leg_takes_the_pairwise_path, *test_utils::even_ranks()) { const int n = world_size(); - if (n < 2 || (n % 2) != 0) { - return; - } const int me = world_rank(); const int peer = me ^ 1; constexpr int len = 3; @@ -170,11 +165,8 @@ BOOST_AUTO_TEST_CASE(flat_exchange_single_leg_takes_the_pairwise_path) { // Every leg active on the pairwise arm: it posts a pair per non-zero leg rather than falling back, so // the transport really is the gate's choice and not the layout's. The self leg is a copy, not a pair. -BOOST_AUTO_TEST_CASE(flat_exchange_dense_layout_on_the_pairwise_arm) { +BOOST_AUTO_TEST_CASE(flat_exchange_dense_layout_on_the_pairwise_arm, *test_utils::power_of_two_ranks()) { const int n = world_size(); - if (n < 2 || (n & (n - 1)) != 0) { - return; // off a power of two the gate resolves to dense, i.e. the collective - } const int me = world_rank(); const std::vector counts(static_cast(n), 1); const auto displs = displs_of(counts); @@ -192,11 +184,8 @@ BOOST_AUTO_TEST_CASE(flat_exchange_dense_layout_on_the_pairwise_arm) { // No active leg anywhere: nothing is posted, wait() drains nothing, and the recv buffer is left as the // caller sized it. A layer with no cross-rank partners takes this shape on EVERY rank at once -- the // symmetric layout is what makes that true -- so the pairwise arm is still the agreed one. -BOOST_AUTO_TEST_CASE(flat_exchange_empty_layout_posts_nothing) { +BOOST_AUTO_TEST_CASE(flat_exchange_empty_layout_posts_nothing, *test_utils::power_of_two_ranks()) { const int n = world_size(); - if (n < 2 || (n & (n - 1)) != 0) { - return; - } const std::vector counts(static_cast(n), 0); BOOST_REQUIRE_EQUAL(total_of(counts), 0); const std::vector send(1, 0); diff --git a/cpp/tests/hybrid_comm_tests.cpp b/cpp/tests/hybrid_comm_tests.cpp index 2910881ee..0545528e0 100644 --- a/cpp/tests/hybrid_comm_tests.cpp +++ b/cpp/tests/hybrid_comm_tests.cpp @@ -20,6 +20,8 @@ #include +#include "RankPreconditions.h" + #ifdef monoprop_ENABLE_MPI #include @@ -65,10 +67,7 @@ auto world_rank() -> int { } // namespace -BOOST_AUTO_TEST_CASE(hybrid_comm_flat_size_and_rank) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_flat_size_and_rank, *test_utils::min_ranks(2)) { const int R = world_size(); for (const int S : {1, 2, 3}) { std::vector seen_size(static_cast(S), -1); @@ -89,10 +88,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_flat_size_and_rank) { } // Each partition contributes its global id, so the expected total is sum_{g int { // The only direct coverage of HybridComm::alltoallv, and the only count matrix varying along both // indices, which is what pins the indexing of col_sum_ and recv_col_. -BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_pairwise_counts) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_pairwise_counts, *test_utils::min_ranks(2)) { const int R = world_size(); const int rounds = 12; for (const int S : {1, 2, 3}) { @@ -355,10 +339,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_pairwise_counts) { // The same pairwise counts through the fused verb, whose recv staging is sized from the count matrix // rather than from published rows. rc/rd are outputs here, and are checked. -BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_resolve_pairwise_counts) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_resolve_pairwise_counts, *test_utils::min_ranks(2)) { const int R = world_size(); const int rounds = 12; for (const int S : {1, 2, 3}) { @@ -424,10 +405,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_resolve_pairwise_counts) { } } -BOOST_AUTO_TEST_CASE(hybrid_comm_allreduce_sum_inplace_global) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_allreduce_sum_inplace_global, *test_utils::min_ranks(2)) { const int R = world_size(); for (const int S : {1, 2, 3}) { const int P = R * S; @@ -464,10 +442,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_allreduce_sum_inplace_global) { // Poison releases barrier waiters on every rank; the test completing at all proves that. Partition 0 poisons // before entering a collective, so no rank is committed to MPI and the partition-0 guard deliberately does // not fire. Poisoning inside a collective calls MPI_Abort instead, so it cannot be a ctest case. -BOOST_AUTO_TEST_CASE(hybrid_comm_poison_releases_waiters) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_poison_releases_waiters, *test_utils::min_ranks(2)) { for (const int S : {2, 3}) { auto errs = run_hybrid(S, [&](HybridComm &hyb, int u) { if (u == 0) { @@ -515,11 +490,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_peer_is_an_involution) { // the two failure modes it can have are DROPPED data and a HANG -- neither of which a dense-path test // can see. Every rank derives the same pairing from the same shift, and a block whose destination is // not the peer must be empty: send only to the plan's peer and check the delivery is exactly that. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_delivers_only_to_its_peers) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_delivers_only_to_its_peers, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; // the XOR pairing needs a power-of-two rank count - } for (const int S : {1, 2, 3}) { const int P = R * S; for (int shift = 0; shift < R; ++shift) { @@ -570,11 +542,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_delivers_only_to_its_peers) { // Same contract on the S == 1 world, which takes the pure-MPI branch (MPI_Ialltoallv vs Isend/Irecv) // rather than HybridComm's staged one, and on the KNOWN-recv-counts path the response round uses. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_on_the_plain_mpi_path) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_on_the_plain_mpi_path, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } Comm c{MPI_COMM_WORLD}; for (int shift = 0; shift < R; ++shift) { const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = shift}; @@ -609,11 +578,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_on_the_plain_mpi_path) { // the response round's transpose is only as masked as whatever produced it. No receive is ever posted // for a non-peer, so an unmasked count sizes recv_buffer for bytes nothing writes and wait_into would // hand that slot to the caller as data. Both the plain-MPI and the staged HybridComm path must drop it. -BOOST_AUTO_TEST_CASE(hybrid_comm_known_recv_counts_are_masked_through_the_plan) { +BOOST_AUTO_TEST_CASE(hybrid_comm_known_recv_counts_are_masked_through_the_plan, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } constexpr int kReal = 4; constexpr int kBogus = 7; // what a stale or unmasked transpose would claim a non-peer is sending for (int shift = 0; shift < R; ++shift) { @@ -694,11 +660,8 @@ auto sparse_tag(int src, int dst, int j) -> int { // A zero-count leg is where a send/recv posting asymmetry deadlocks rather than mis-delivers: both ends // must skip on the SAME value. Nothing above ever sends an empty block over a real message, so force // one -- the lower-numbered end of every pair sends nothing while its peer sends four. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_with_an_empty_leg) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_with_an_empty_leg, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); constexpr int kLen = 4; for (int shift = 1; shift < R; ++shift) { // shift 0 is the self peer, covered separately @@ -760,11 +723,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_with_an_empty_leg) { // shift == 0 makes every rank its own and only peer, so the sparse path's self slot is the whole // exchange -- and skip_self then removes the one leg that would have moved anything for a partition. // The remaining S-1 in-rank legs must still arrive. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_skip_self_at_shift_zero) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_skip_self_at_shift_zero, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = 0}; BOOST_REQUIRE_EQUAL(plan.peer(me, 0), me); @@ -820,11 +780,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_skip_self_at_shift_zero) { // Engine.h's run_exchange posts both its rounds on ONE communicator under kFlatPayloadTag, and the // non-overtaking argument in Pairwise.h is what says a round-2 receive cannot match a round-1 send. // Two back-to-back sparse rounds, the second on the transpose of the first's counts, assert it. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_back_to_back_rounds) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_back_to_back_rounds, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); Comm c{MPI_COMM_WORLD}; for (int shift = 0; shift < R; ++shift) { @@ -912,11 +869,8 @@ auto muted_count(int src, int dst, int S) -> int { // pack_send_ running underneath. Only the sparse arm splits -- plan.dense() is a blocking MPI_Alltoall // -- so drive the SAME peer-masked layout through both arms and require the delivered blocks to agree // element for element. A dropped or torn count block changes a length here, not just a value. -BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_matches_the_dense_arm) { +BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_matches_the_dense_arm, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; // the XOR pairing needs a power-of-two rank count - } const int me = world_rank(); for (int shift = 0; shift < R; ++shift) { const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = shift}; @@ -971,11 +925,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_matches_the_dense_arm // shift == 0 at full bits makes this rank its own and only peer, so the count round posts NOTHING: // sparse_pairwise memcpys the S*S-int block in place and the B3→B4 wait must be a no-op rather than a // wait on stale requests. The S^2 in-rank payload legs still have to arrive. -BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_self_peer_only) { +BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_self_peer_only, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = 0}; BOOST_REQUIRE_EQUAL(plan.peer(me, 0), me); @@ -1015,11 +966,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_self_peer_only) { // A peer with nothing to send is where the split can hang: pack_send_ copies zero bytes in B2→B3 while // the count round is still live, and the payload round posts no leg for it at all. Back-to-back rounds, // the second silent, so the second also runs over the first's staging high-water bytes. -BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_zero_count_peer) { +BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_zero_count_peer, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); for (int shift = 1; shift < R; ++shift) { // shift 0 is the self peer, covered above const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = shift}; @@ -1076,11 +1024,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_zero_count_peer) { // The multi-peer and lossy-plan refusals in the same window are NOT exercised here: they reach MPI // through guard_partition0_, which turns a throw into MPI_Abort, so a case for them would take the // runner down with it. -BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_reads_every_partitions_row) { +BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_reads_every_partitions_row, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; // the XOR pairing needs a power-of-two rank count - } const int me = world_rank(); const int peer = me ^ 1; // shift 1, so every rank derives the same pairing constexpr int kLen = 5; @@ -1137,11 +1082,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_reads_every_partitions_row) { // The rank-uniform gate picks the transport; the peer set is only a narrowing, so a rank whose rows do // not pin one peer down keeps the full set and posts point-to-point over all of it. Before, this shape // aborted the job from inside guard_partition0_. -BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_falls_back_to_every_peer) { +BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_falls_back_to_every_peer, *test_utils::power_of_two_ranks(4)) { const int R = world_size(); - if (R < 4 || (R & (R - 1)) != 0) { - return; // two distinct non-self peers is the point, so R = 2 cannot show it - } const int me = world_rank(); // XOR by 1 and by 2: an involution each, so the count matrix stays symmetric and every rank spans // exactly two peers -- which is what makes this a valid layout the narrowing simply cannot use. @@ -1203,11 +1145,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_falls_back_to_every_peer) { // A sparse plan is a public aggregate, so a shift outside the rank-index width reaches the transports // as an out-of-range vector index and an invalid MPI rank. Every sparse entry point refuses it first, // and it must be an exception rather than an assert: release builds are where it would be indexed. -BOOST_AUTO_TEST_CASE(hybrid_comm_unroutable_peer_plan_is_refused) { +BOOST_AUTO_TEST_CASE(hybrid_comm_unroutable_peer_plan_is_refused, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const monoprop::mpi::PeerPlan bad{.sparse = true, .shift = R}; const int P = R; std::vector> send(static_cast(P)); @@ -1222,7 +1161,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_unroutable_peer_plan_is_refused) { // MPI reads the payload buffers until the requests retire, so the handle that owns them must not be // copyable (two owners, one set of requests) and must not be destroyable while they are live. -BOOST_AUTO_TEST_CASE(hybrid_comm_pending_alltoallv_owns_its_requests) { +BOOST_AUTO_TEST_CASE(hybrid_comm_pending_alltoallv_owns_its_requests, *test_utils::power_of_two_ranks()) { using Pending = monoprop::mpi::PendingAlltoallv; static_assert(!std::is_copy_constructible_v); static_assert(!std::is_copy_assignable_v); @@ -1231,9 +1170,6 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_pending_alltoallv_owns_its_requests) { // Dropping a handle without waiting must still complete the transfer, not free the buffers under it. const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); Comm c{MPI_COMM_WORLD}; std::vector> send(static_cast(R)); diff --git a/cpp/tests/mpi_distributed_layer_equivalence.cpp b/cpp/tests/mpi_distributed_layer_equivalence.cpp index 152eb2b75..c7ce766c8 100644 --- a/cpp/tests/mpi_distributed_layer_equivalence.cpp +++ b/cpp/tests/mpi_distributed_layer_equivalence.cpp @@ -21,6 +21,7 @@ #include #include "PauliTestOracle.h" +#include "RankPreconditions.h" #include "TestUtilities.h" #include "monoprop/MonomialPropagator.h" #include "monoprop/detail/mpi/MPICompat.h" @@ -61,11 +62,7 @@ auto run_energy(const TestInputs& inputs, MPI_Comm comm) -> double { return fn(inputs.data.parameters); } -BOOST_AUTO_TEST_CASE(rank_count_energy_within_fp_tolerance) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping cross-rank-count case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(rank_count_energy_within_fp_tolerance, *test_utils::min_ranks(2)) { const auto inputs = load_inputs(); const double e_serial = run_energy(inputs, MPI_COMM_SELF); const double e_world = run_energy(inputs, MPI_COMM_WORLD); @@ -73,11 +70,7 @@ BOOST_AUTO_TEST_CASE(rank_count_energy_within_fp_tolerance) { BOOST_TEST(near(e_serial, e_world)); } -BOOST_AUTO_TEST_CASE(gradient_rank_count_within_fp_tolerance) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping gradient cross-rank-count case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(gradient_rank_count_within_fp_tolerance, *test_utils::min_ranks(2)) { const auto& inputs = load_inputs(); auto run_gradient = [&](MPI_Comm comm) -> VecD { @@ -149,11 +142,7 @@ auto run_pauli_energy(MPI_Comm comm) -> double { return sim.expectation_value({}); } -BOOST_AUTO_TEST_CASE(pauli_rank_count_energy_within_fp_tolerance) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping Pauli cross-rank-count case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(pauli_rank_count_energy_within_fp_tolerance, *test_utils::min_ranks(2)) { const double e_serial = run_pauli_energy(MPI_COMM_SELF); const double e_world = run_pauli_energy(MPI_COMM_WORLD); BOOST_TEST_MESSAGE("pauli serial=" << e_serial << " world=" << e_world); @@ -184,11 +173,7 @@ auto run_energy_partitioned(const TestInputs& inputs, MPI_Comm comm, size_t part return {e, sim.size()}; } -BOOST_AUTO_TEST_CASE(hybrid_mpi_partition_energy_and_size_equivalence) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping hybrid case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(hybrid_mpi_partition_energy_and_size_equivalence, *test_utils::min_ranks(2)) { const auto inputs = load_inputs(); const auto [e_serial, n_serial] = run_energy_partitioned(inputs, MPI_COMM_SELF, 1); const auto [e_hybrid, n_local] = run_energy_partitioned(inputs, MPI_COMM_WORLD, 2); diff --git a/cpp/tests/mpi_fresh_insert_equivalence.cpp b/cpp/tests/mpi_fresh_insert_equivalence.cpp index 2973131b6..68a38bb19 100644 --- a/cpp/tests/mpi_fresh_insert_equivalence.cpp +++ b/cpp/tests/mpi_fresh_insert_equivalence.cpp @@ -26,6 +26,7 @@ #include #include "PauliTestOracle.h" +#include "RankPreconditions.h" #include "TestUtilities.h" #include "monoprop/MonomialPropagator.h" #include "monoprop/detail/mpi/MPICompat.h" @@ -54,11 +55,9 @@ auto run_schrodinger_majorana(const CaseData& data, MPI_Comm comm) -> double { return energy_fn(VecD{}); } -BOOST_FIXTURE_TEST_CASE(mpi_fresh_insert_schrodinger_majorana_serial_world_equiv, ExampleDataFix) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping Schrödinger Majorana fresh-insert equivalence (world size = 1)."); - return; - } +BOOST_FIXTURE_TEST_CASE(mpi_fresh_insert_schrodinger_majorana_serial_world_equiv, + ExampleDataFix, + *test_utils::min_ranks(2)) { const double e_serial = run_schrodinger_majorana(data, MPI_COMM_SELF); const double e_world = run_schrodinger_majorana(data, MPI_COMM_WORLD); BOOST_TEST_MESSAGE("schrodinger majorana serial=" << e_serial << " world=" << e_world); @@ -107,11 +106,7 @@ auto run_schrodinger_pauli(MPI_Comm comm) -> double { return sim.expectation_value({}); } -BOOST_AUTO_TEST_CASE(mpi_fresh_insert_schrodinger_pauli_serial_world_equiv) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping Schrödinger Pauli fresh-insert equivalence (world size = 1)."); - return; - } +BOOST_AUTO_TEST_CASE(mpi_fresh_insert_schrodinger_pauli_serial_world_equiv, *test_utils::min_ranks(2)) { const double e_serial = run_schrodinger_pauli(MPI_COMM_SELF); const double e_world = run_schrodinger_pauli(MPI_COMM_WORLD); BOOST_TEST_MESSAGE("schrodinger pauli serial=" << e_serial << " world=" << e_world); diff --git a/cpp/tests/mpi_pare.cpp b/cpp/tests/mpi_pare.cpp index 0e57f1620..969952dfc 100644 --- a/cpp/tests/mpi_pare.cpp +++ b/cpp/tests/mpi_pare.cpp @@ -16,16 +16,13 @@ #include +#include "RankPreconditions.h" #include "TestUtilities.h" using namespace test_utils; -BOOST_AUTO_TEST_CASE(multi_rank_pare_expval_is_finite) { +BOOST_AUTO_TEST_CASE(multi_rank_pare_expval_is_finite, *test_utils::min_ranks(2)) { const int world_size = monoprop::mpi::size(MPI_COMM_WORLD); - if (world_size < 2) { - BOOST_TEST_MESSAGE("Skipping multi_rank_pare_expval_is_finite: MPI world size=" << world_size); - return; - } constexpr size_t NumModes = 8; constexpr double kExpvalAtol = 1e-9; diff --git a/cpp/tests/routing_tests.cpp b/cpp/tests/routing_tests.cpp index 1e3375b75..97be6f0bf 100644 --- a/cpp/tests/routing_tests.cpp +++ b/cpp/tests/routing_tests.cpp @@ -31,6 +31,7 @@ #include #include +#include "RankPreconditions.h" #include "monoprop/algebra/MajoranaAlgebra.h" #include "monoprop/detail/mpi/Comm.h" #include "monoprop/detail/mpi/MPIUtils.h" @@ -404,14 +405,8 @@ BOOST_AUTO_TEST_CASE(routing_gf2_rank_detects_a_degenerate_shift_set) { // The SHIPPED default, pinned by a test rather than left to a comment: with no environment override a // power-of-two rank count routes linearly at fanout 1, a single rank routes densely, and a geometry -// with no XOR structure is refused. Skipped when the environment does override it, because then the -// default is not what is under test. -BOOST_AUTO_TEST_CASE(routing_default_is_linear_where_the_geometry_allows_it) { - const char *mode = std::getenv("monoprop_ROUTING"); - if (mode != nullptr && *mode != '\0') { - BOOST_TEST_MESSAGE("routing overridden in the environment; default not under test"); - return; - } +// with no XOR structure is refused. +BOOST_AUTO_TEST_CASE(routing_default_is_linear_where_the_geometry_allows_it, *test_utils::default_routing()) { BOOST_TEST(routing::make_router(8, 14).is_linear()); BOOST_TEST(routing::make_router(128, 14).linear_bits() == 7U); BOOST_TEST(!routing::make_router(1, 112).is_linear()); // single rank: nothing to route between From 538dab14aa8c81a1cfecef2a4a50c609e9b3ef2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 12:20:47 +0100 Subject: [PATCH 03/15] =?UTF-8?q?test(cpp):=20=F0=9F=90=9B=20compare=20ser?= =?UTF-8?q?ial=20and=20distributed=20runs=20on=20references=20truncation?= =?UTF-8?q?=20does=20not=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- cpp/tests/mpi_distributed_layer_equivalence.cpp | 14 +++++++++++++- cpp/tests/mpi_fresh_insert_equivalence.cpp | 6 +++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cpp/tests/mpi_distributed_layer_equivalence.cpp b/cpp/tests/mpi_distributed_layer_equivalence.cpp index c7ce766c8..9b8e62a81 100644 --- a/cpp/tests/mpi_distributed_layer_equivalence.cpp +++ b/cpp/tests/mpi_distributed_layer_equivalence.cpp @@ -14,6 +14,7 @@ #include +#include #include #include #include @@ -37,7 +38,8 @@ using namespace test_utils; using pauli_oracle::slots_of_string; constexpr size_t kNumModes = 8; -constexpr unsigned int kCutoff = 4; +// random_exact's observable has only terms of length >= 10, so a lower cutoff leaves the energy 0. +constexpr unsigned int kCutoff = 10; struct TestInputs { CaseData data; @@ -47,6 +49,12 @@ auto load_inputs() -> TestInputs { return {load_case_data("random_exact.msgpack")}; } +// A reference that truncation zeroes out, or leaves exact, matches a broken exchange too. +auto require_truncated_reference(double serial, double exact) -> void { + BOOST_REQUIRE_MESSAGE(std::abs(serial) > 1e-6, "serial reference is trivially zero: " << serial); + BOOST_REQUIRE_MESSAGE(!near(serial, exact, 1e-6), "serial reference is not truncated: " << serial); +} + auto run_energy(const TestInputs& inputs, MPI_Comm comm) -> double { MonomialPropagator sim(inputs.data.hamiltonian, kCutoff, @@ -65,6 +73,7 @@ auto run_energy(const TestInputs& inputs, MPI_Comm comm) -> double { BOOST_AUTO_TEST_CASE(rank_count_energy_within_fp_tolerance, *test_utils::min_ranks(2)) { const auto inputs = load_inputs(); const double e_serial = run_energy(inputs, MPI_COMM_SELF); + require_truncated_reference(e_serial, inputs.data.actual_expval); const double e_world = run_energy(inputs, MPI_COMM_WORLD); BOOST_TEST_MESSAGE("serial=" << e_serial << " world=" << e_world << " diff=" << (e_world - e_serial)); BOOST_TEST(near(e_serial, e_world)); @@ -89,6 +98,8 @@ BOOST_AUTO_TEST_CASE(gradient_rank_count_within_fp_tolerance, *test_utils::min_r }; const auto g_serial = run_gradient(MPI_COMM_SELF); + BOOST_REQUIRE_MESSAGE(std::ranges::any_of(g_serial, [](double g) { return std::abs(g) > 1e-6; }), + "serial gradient is trivially zero"); const auto g_world = run_gradient(MPI_COMM_WORLD); BOOST_REQUIRE_EQUAL(g_serial.size(), g_world.size()); @@ -176,6 +187,7 @@ auto run_energy_partitioned(const TestInputs& inputs, MPI_Comm comm, size_t part BOOST_AUTO_TEST_CASE(hybrid_mpi_partition_energy_and_size_equivalence, *test_utils::min_ranks(2)) { const auto inputs = load_inputs(); const auto [e_serial, n_serial] = run_energy_partitioned(inputs, MPI_COMM_SELF, 1); + require_truncated_reference(e_serial, inputs.data.actual_expval); const auto [e_hybrid, n_local] = run_energy_partitioned(inputs, MPI_COMM_WORLD, 2); // Each rank's facade holds only its local partitions; the global term count is the cross-rank sum. const size_t n_hybrid_global = mpi::allreduce_sum(n_local, MPI_COMM_WORLD); diff --git a/cpp/tests/mpi_fresh_insert_equivalence.cpp b/cpp/tests/mpi_fresh_insert_equivalence.cpp index 68a38bb19..8e86bb69d 100644 --- a/cpp/tests/mpi_fresh_insert_equivalence.cpp +++ b/cpp/tests/mpi_fresh_insert_equivalence.cpp @@ -20,6 +20,7 @@ #include +#include #include #include #include @@ -39,12 +40,14 @@ using pauli_oracle::slots_of_string; // schrodinger_cutoff engages the picture; a low structural cutoff plus the upper_atol = 0 rescue // forces most partners to be fresh inserts, so the miss arm runs on nearly every partner. +// Schrödinger 10, not lower: random_exact's observable terms all have length >= 10, and a lower +// state cutoff reads them all as 0. template auto run_schrodinger_majorana(const CaseData& data, MPI_Comm comm) -> double { MonomialPropagator sim(data.hamiltonian, /*cutoff=*/2U, data.initial_state, - /*schrodinger_cutoff=*/std::optional{4U}, + /*schrodinger_cutoff=*/std::optional{10U}, comm, /*lower_atol=*/std::nullopt, /*upper_atol=*/std::optional{0.0}, @@ -59,6 +62,7 @@ BOOST_FIXTURE_TEST_CASE(mpi_fresh_insert_schrodinger_majorana_serial_world_equiv ExampleDataFix, *test_utils::min_ranks(2)) { const double e_serial = run_schrodinger_majorana(data, MPI_COMM_SELF); + BOOST_REQUIRE_MESSAGE(std::abs(e_serial) > 1e-6, "serial reference is trivially zero: " << e_serial); const double e_world = run_schrodinger_majorana(data, MPI_COMM_WORLD); BOOST_TEST_MESSAGE("schrodinger majorana serial=" << e_serial << " world=" << e_world); BOOST_TEST(near(e_serial, e_world)); From 94fef2a165a98d0e7a0b7a4bce06d73d2de712c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 12:34:07 +0100 Subject: [PATCH 04/15] =?UTF-8?q?test(cpp):=20=E2=9C=85=20check=20that=20l?= =?UTF-8?q?inear=20routing=20refuses=20a=20three-rank=20communicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- cpp/tests/mpi_routing_geometry_tests.cpp | 62 ++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 cpp/tests/mpi_routing_geometry_tests.cpp diff --git a/cpp/tests/mpi_routing_geometry_tests.cpp b/cpp/tests/mpi_routing_geometry_tests.cpp new file mode 100644 index 000000000..557bc180b --- /dev/null +++ b/cpp/tests/mpi_routing_geometry_tests.cpp @@ -0,0 +1,62 @@ +// Copyright 2026 Algorithmiq +// +// 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 + +#include + +#include "RankPreconditions.h" +#include "TestUtilities.h" +#include "monoprop/MonomialPropagator.h" +#include "monoprop/detail/mpi/MPICompat.h" +#include "monoprop/detail/mpi/Routing.h" + +#ifdef monoprop_ENABLE_MPI + +#include + +namespace { + +using namespace monoprop; +using namespace test_utils; + +constexpr size_t kNumModes = 8; + +} // namespace + +// Linear routing XORs rank indices, so it has no routing for 3 ranks: construction must refuse on +// every member rank instead of hanging the first exchange. +BOOST_AUTO_TEST_CASE(linear_routing_refuses_a_three_rank_communicator, + *test_utils::min_ranks(3) * test_utils::linear_routing()) { + const int world_rank = mpi::rank(MPI_COMM_WORLD); + MPI_Comm three_ranks = MPI_COMM_NULL; + MPI_Comm_split(MPI_COMM_WORLD, world_rank < 3 ? 0 : MPI_UNDEFINED, world_rank, &three_ranks); + if (three_ranks == MPI_COMM_NULL) { + return; // outside the three-rank communicator there is nothing to construct + } + const auto data = load_case_data("random_exact.msgpack"); + BOOST_CHECK_THROW(static_cast(MonomialPropagator(data.hamiltonian, + 2 * kNumModes, + data.initial_state, + std::nullopt, + three_ranks, + std::nullopt, + std::nullopt, + CutoffType::Length, + std::nullopt)), + routing::UnroutableGeometry); + MPI_Comm_free(&three_ranks); +} + +#endif // monoprop_ENABLE_MPI From fb8de41cfc9530132b067639d8ca31f3c697c32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 12:39:03 +0100 Subject: [PATCH 05/15] =?UTF-8?q?test(py):=20=E2=99=BB=EF=B8=8F=20run=20se?= =?UTF-8?q?rial=20tests=20with=20comm=3DNone=20and=20distribute=20through?= =?UTF-8?q?=20a=20COMM=5FWORLD=20duplicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- .../monoprop-bench-tools/tests/conftest.py | 20 ------- .../monoprop-bench-tools/tests/test_models.py | 6 +- tests/conftest.py | 55 ++++++------------- tests/test_basis.py | 10 ++-- tests/test_coeff_trunc.py | 18 ++---- tests/test_evolved_operator_coefficients.py | 34 +++++------- tests/test_monoprop_mpi.py | 4 +- tests/test_monoprop_smoke.py | 39 ++++++------- tests/test_monoprop_trivial.py | 13 ++--- tests/test_mpi_configuration.py | 13 +++++ tests/test_nonfermi.py | 3 +- tests/test_only_rotate_k.py | 12 ++-- tests/test_parameter_validation.py | 34 +++++------- tests/test_pauli.py | 25 ++++----- tests/test_term_expectation_values.py | 48 ++++++++-------- tests/test_update_methods.py | 10 +--- 16 files changed, 139 insertions(+), 205 deletions(-) diff --git a/packages/monoprop-bench-tools/tests/conftest.py b/packages/monoprop-bench-tools/tests/conftest.py index bf3e4f15d..c488905d4 100644 --- a/packages/monoprop-bench-tools/tests/conftest.py +++ b/packages/monoprop-bench-tools/tests/conftest.py @@ -15,23 +15,3 @@ """Pytest configuration for the monoprop-bench-tools test suite.""" from __future__ import annotations - -from typing import Any - -import pytest - -import monoprop - -try: - from mpi4py import MPI -except ImportError: - MPI = None - - -@pytest.fixture -def serial_comm() -> Any: - """Single-rank communicator for builders whose state is rank-local. - - ``None`` unless mpi4py imports and this monoprop build has an MPI backend. - """ - return None if MPI is None or not monoprop.MPI_ABIS else MPI.COMM_SELF diff --git a/packages/monoprop-bench-tools/tests/test_models.py b/packages/monoprop-bench-tools/tests/test_models.py index 02a6c6bed..33610c486 100644 --- a/packages/monoprop-bench-tools/tests/test_models.py +++ b/packages/monoprop-bench-tools/tests/test_models.py @@ -36,15 +36,15 @@ def test_random_default_sizes_are_meaningful() -> None: assert defaults["seed"] is None -def test_built_graph_is_populated(serial_comm) -> None: +def test_built_graph_is_populated() -> None: # gen_length=4: a length-4 Majorana monomial is Hermitian with real coefficients, while a # length-2 one is anti-Hermitian and would be rejected. - # serial_comm because graph_size() is rank-local: on COMM_WORLD a problem this small leaves + # Serial: graph_size() is rank-local, and on COMM_WORLD a problem this small leaves # some ranks with no local cycles. problem = make_random_problem( gen_length=4, obs_terms=3, num_generators=5, num_modes=6, cutoff=3, seed=0 ) - propagator, circuit = build_random_propagator(problem, comm=serial_comm) + propagator, circuit = build_random_propagator(problem) propagator.build_graph(circuit) _n_cos_indices, n_cycles = propagator.graph_size() assert n_cycles > 0 diff --git a/tests/conftest.py b/tests/conftest.py index e5c700a37..f795919f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,15 +16,17 @@ MPI selection is handled here (``--with-mpi``, ``@pytest.mark.mpi``). -``comm`` is parametrized over COMM_SELF and COMM_WORLD: use it for energies and gradients, which -allreduce internally. ``serial_comm`` is always COMM_SELF: use it for rank-local state such as -``evolved_operator``, ``contract_partially``, ``size()``, or ``graph_size()``. - -Both hand out session-wide duplicates of those communicators, never the predefined handles -themselves, as the parallelism docs tell users to do: monoprop's point-to-point traffic then -cannot match messages that conftest or mpi4py exchange on the predefined communicators. -Each duplicate is created on first use and freed at session teardown: ``Dup`` is collective, and -doing it per test would add a collective to every one of them for no extra isolation. +``comm`` is parametrized over ``None``, the serial run, and, with an MPI backend, ``comm_world``. +The world case carries the ``mpi`` marker, so it runs only under ``--with-mpi`` and is what +``-m mpi`` selects. Use ``comm`` for results every rank agrees on: energies, gradients, +``term_expectation_values``. Test rank-local state (``evolved_operator``, ``contract_partially``, +``size()``, ``graph_size()``) serially, with ``comm=None``. + +``comm_world`` is a session-wide duplicate of COMM_WORLD, never the predefined handle itself, as +the parallelism docs tell users to do: monoprop's point-to-point traffic then cannot match +messages that conftest or mpi4py exchange on COMM_WORLD. It is created on first use and freed at +session teardown: ``Dup`` is collective, and doing it per test would add a collective to every +one of them for no extra isolation. Under more than one rank each rank gets its own ``--basetemp``; see ``_rank_basetemp``. """ @@ -157,17 +159,6 @@ def pytest_runtest_setup(item: pytest.Item) -> None: pytest.skip(f"Test requires {min_size} MPI processes") -@pytest.fixture(scope="session") -def comm_self() -> Iterator[Intracomm | None]: - """Session-wide duplicate of COMM_SELF; ``None`` when monoprop has no MPI backend.""" - if not HAS_MPI_BACKEND: - yield None - return - duplicate = MPI.COMM_SELF.Dup() - yield duplicate - duplicate.Free() - - @pytest.fixture(scope="session") def comm_world() -> Iterator[Intracomm | None]: """Session-wide duplicate of COMM_WORLD; ``None`` when monoprop has no MPI backend.""" @@ -179,33 +170,21 @@ def comm_world() -> Iterator[Intracomm | None]: duplicate.Free() -_COMM_PARAMS = ( - [pytest.param(None, id="comm_none")] - if not HAS_MPI_BACKEND - else [ - pytest.param("comm_self", id="comm_self"), - pytest.param("comm_world", id="comm_world"), - ] -) +_COMM_PARAMS = [pytest.param(None, id="comm_none")] +if HAS_MPI_BACKEND: + _COMM_PARAMS.append( + pytest.param("comm_world", id="comm_world", marks=pytest.mark.mpi) + ) @pytest.fixture(params=_COMM_PARAMS) def comm(request: pytest.FixtureRequest) -> Intracomm | None: - """Parametrized communicator; ``None`` when mpi4py is unavailable or monoprop has no MPI backend. - - Resolved through the session-scoped duplicates, so parametrizing does not reorder the run. - """ + """``None`` (serial), or ``comm_world`` under ``--with-mpi`` when monoprop has an MPI backend.""" if request.param is None: return None return request.getfixturevalue(request.param) -@pytest.fixture -def serial_comm(comm_self: Intracomm | None) -> Intracomm | None: - """Single-rank communicator for tests that inspect rank-local operator state.""" - return comm_self - - @pytest.fixture( params=[ pytest.param("lih_fermionic_spin_exact", id="LiH_fermionic_spin"), diff --git a/tests/test_basis.py b/tests/test_basis.py index 02aad0a94..67e6e4562 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -35,19 +35,19 @@ N_MODES = 6 -def _propagator(comm=None) -> MajoranaPropagator: +def _propagator() -> MajoranaPropagator: return MajoranaPropagator( - MajoranaOperator({(0,): 1.0}, N_MODES), [], cutoff=N_MODES // 2, comm=comm + MajoranaOperator({(0,): 1.0}, N_MODES), [], cutoff=N_MODES // 2 ) -def test_basis_change(serial_comm) -> None: +def test_basis_change() -> None: """A Jordan-Wigner cutoff basis reproduces the exact single-rotation result. - serial_comm because evolved_operator() is rank-local: on COMM_WORLD the single term lives on + Serial: evolved_operator() is rank-local, and on COMM_WORLD the single term lives on whichever rank owns its hash partition. """ - propagator = _propagator(serial_comm) + propagator = _propagator() propagator._simulator.basis_change = jordan_wigner_basis_change(N_MODES) propagator.propagate( Circuit.from_dense_arrays( diff --git a/tests/test_coeff_trunc.py b/tests/test_coeff_trunc.py index da224017c..65d4bdbe7 100644 --- a/tests/test_coeff_trunc.py +++ b/tests/test_coeff_trunc.py @@ -30,7 +30,6 @@ def _create_mp( *, op, initial_state, - comm, upper_atol=None, lower_atol=None, cutoff=4, @@ -43,7 +42,6 @@ def _create_mp( upper_atol=upper_atol, lower_atol=lower_atol, schrodinger_cutoff=schrodinger_cutoff, - comm=comm, ) @@ -60,7 +58,7 @@ def _check_dicts(d1, d2): ) -def test_coeff_trunc(serial_comm): +def test_coeff_trunc(): n_modes = 5 op = MajoranaOperator({(0, 1): 1.0j}, num_modes=n_modes) @@ -82,7 +80,6 @@ def get_test_dict(upper_atol, lower_atol, cutoff): mp = _create_mp( op=op, initial_state=sequence.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -114,7 +111,6 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( upper_atol, lower_atol, cutoff, - serial_comm, ): """Check that build graph with coeffs and inplace version match.""" n_modes = lih_problem.n_modes @@ -127,7 +123,6 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( mp_inplace = _create_mp( op=fermionic_operator, initial_state=monomial_circuit.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -139,7 +134,6 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( mp_build = _create_mp( op=fermionic_operator, initial_state=monomial_circuit.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -153,7 +147,7 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( assert np.isclose(expval_inplace, expval_build, atol=1e-12) -def test_evolution_coeff_trunc_no_atols(serial_comm): +def test_evolution_coeff_trunc_no_atols(): n_modes = 2 p = np.pi / 16 @@ -180,7 +174,6 @@ def test_evolution_coeff_trunc_no_atols(serial_comm): sequence.initial_state, cutoff=cutoff, schrodinger_cutoff=None, - comm=serial_comm, ) mp.build_graph(circuit) assert mp.graph_size()[1] == 1 @@ -192,7 +185,7 @@ def test_evolution_coeff_trunc_no_atols(serial_comm): _check_dicts(test_op, final_operator) -def test_evolution_coeff_trunc_small_coeffs(serial_comm): +def test_evolution_coeff_trunc_small_coeffs(): n_modes = 2 p = 1e-5 init_op = {(0, 1): 1.0j, (0, 2): 1e-7j} @@ -229,7 +222,6 @@ def test_evolution_coeff_trunc_small_coeffs(serial_comm): mp = _create_mp( op=op, initial_state=sequence.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -245,7 +237,7 @@ def test_evolution_coeff_trunc_small_coeffs(serial_comm): _check_dicts(test_op, final_operator) -def test_graph_size_counts_real_cosine_indices(serial_comm): +def test_graph_size_counts_real_cosine_indices(): """``graph_size()[0]`` is the real cosine-only count, not a structural zero. Cutoff 4 truncates the weight-6 sine partners of these weight-4 terms, leaving their sources @@ -263,7 +255,7 @@ def test_graph_size_counts_real_cosine_indices(serial_comm): ) def sized(cutoff: int) -> tuple[int, int]: - mp = MajoranaPropagator(operator, [], cutoff=cutoff, comm=serial_comm) + mp = MajoranaPropagator(operator, [], cutoff=cutoff) mp.build_graph(circuit) return mp.graph_size() diff --git a/tests/test_evolved_operator_coefficients.py b/tests/test_evolved_operator_coefficients.py index a28bf7a05..e9fee5ae0 100644 --- a/tests/test_evolved_operator_coefficients.py +++ b/tests/test_evolved_operator_coefficients.py @@ -15,7 +15,7 @@ """Coverage for ``evolved_operator_coefficients``, the term-probing companion to ``evolved_operator``. Oracle throughout: ``evolved_operator(atol=0.0)``, which enumerates the whole index. Both are -rank-local, so every propagator here is built on ``serial_comm`` (see the note in ``test_basis.py``). +rank-local, so every propagator here runs serially (see the note in ``test_basis.py``). """ from __future__ import annotations @@ -47,7 +47,7 @@ def problem(): @pytest.fixture(scope="module") -def prop_and_params(problem, comm_self): +def prop_and_params(problem): """A built propagator over ``problem`` and its parameters, shared across the module. ``evolved_operator`` and ``evolved_operator_coefficients`` leave simulator state alone, so one @@ -57,20 +57,18 @@ def prop_and_params(problem, comm_self): problem.operator, problem.monomial_circuit.initial_state, cutoff=2 * problem.n_modes, - comm=comm_self, ) prop.build_graph(problem.monomial_circuit.to_circuit()) return prop, problem.monomial_circuit.parameters -def _pauli_propagator(serial_comm, schrodinger_cutoff=None): +def _pauli_propagator(schrodinger_cutoff=None): """A Pauli propagator with a graph whose evolution spreads the operator over many terms.""" prop = PauliPropagator( PauliOperator({Pauli("ZZ", (0, 1)): 1.0, Pauli("XY", (2, 3)): 0.5}, N_QUBITS), initial_state=[], cutoff=N_QUBITS, schrodinger_cutoff=schrodinger_cutoff, - comm=serial_comm, ) circuit = Circuit( tuple( @@ -89,9 +87,9 @@ def _pauli_propagator(serial_comm, schrodinger_cutoff=None): return prop, np.linspace(0.1, 0.9, prop.n_parameters) -def test_pauli_coefficients_match_evolved_operator(serial_comm) -> None: +def test_pauli_coefficients_match_evolved_operator() -> None: """Every term the evolved Pauli operator carries reads back with the same coefficient.""" - prop, parameters = _pauli_propagator(serial_comm) + prop, parameters = _pauli_propagator() evolved = prop.evolved_operator(parameters, atol=0.0) terms = list(evolved.terms) @@ -166,13 +164,12 @@ def test_non_canonical_term_in_a_long_query_names_the_offender(prop_and_params) prop.evolved_operator_coefficients(terms, parameters) -def test_absent_term_reads_back_zero(serial_comm) -> None: +def test_absent_term_reads_back_zero() -> None: """A term the evolved operator does not carry is 0, not a raise and not noise.""" prop = PauliPropagator( PauliOperator({Pauli("Z", (0,)): 1.0}, N_QUBITS), initial_state=[], cutoff=N_QUBITS, - comm=serial_comm, ) absent = Pauli("X", (5,)) @@ -184,9 +181,9 @@ def test_absent_term_reads_back_zero(serial_comm) -> None: assert coefficients[0] == 0 -def test_schrodinger_coefficients_match_evolved_state(serial_comm) -> None: +def test_schrodinger_coefficients_match_evolved_state() -> None: """The motivating case: reading a few amplitudes out of an evolved state.""" - prop, parameters = _pauli_propagator(serial_comm, schrodinger_cutoff=4) + prop, parameters = _pauli_propagator(schrodinger_cutoff=4) evolved = prop.evolved_operator(parameters, atol=0.0) terms = list(evolved.terms) @@ -221,7 +218,7 @@ def test_query_order_is_preserved(prop_and_params) -> None: ) -def test_identity_term_is_the_core_term(problem, serial_comm) -> None: +def test_identity_term_is_the_core_term(problem) -> None: """In the Heisenberg picture the empty term is the core term ``evolved_operator`` re-adds. The fixture Hamiltonian carries no identity term, so one is injected: with a zero core term the @@ -234,7 +231,6 @@ def test_identity_term_is_the_core_term(problem, serial_comm) -> None: with_identity, problem.monomial_circuit.initial_state, cutoff=2 * problem.n_modes, - comm=serial_comm, ) prop.build_graph(problem.monomial_circuit.to_circuit()) parameters = problem.monomial_circuit.parameters @@ -247,9 +243,9 @@ def test_identity_term_is_the_core_term(problem, serial_comm) -> None: assert coefficients[0] == pytest.approx(evolved.terms[()]) -def test_schrodinger_identity_is_a_state_amplitude(serial_comm) -> None: +def test_schrodinger_identity_is_a_state_amplitude() -> None: """In the Schrodinger picture the identity is an ordinary term, not a core term.""" - prop, parameters = _pauli_propagator(serial_comm, schrodinger_cutoff=4) + prop, parameters = _pauli_propagator(schrodinger_cutoff=4) evolved = prop.evolved_operator(parameters, atol=0.0) identity = Pauli("", ()) @@ -354,17 +350,17 @@ def test_majorana_front_end_rejects_a_pauli_term(prop_and_params) -> None: prop.evolved_operator_coefficients(paulis, parameters) -def test_pauli_front_end_rejects_a_raw_slot_tuple(serial_comm) -> None: +def test_pauli_front_end_rejects_a_raw_slot_tuple() -> None: """Symplectic slots are an engine-internal encoding, so the Pauli front-end takes Pauli only.""" - prop, parameters = _pauli_propagator(serial_comm) + prop, parameters = _pauli_propagator() with pytest.raises(TypeError, match="Pauli objects"): prop.evolved_operator_coefficients([(0, 1)], parameters) -def test_repeated_term_is_answered_once_per_occurrence(serial_comm) -> None: +def test_repeated_term_is_answered_once_per_occurrence() -> None: """The query is a list, not a set: each occurrence gets its own slot with the same value.""" - prop, parameters = _pauli_propagator(serial_comm) + prop, parameters = _pauli_propagator() evolved = prop.evolved_operator(parameters, atol=0.0) term = next(iter(evolved.terms)) diff --git a/tests/test_monoprop_mpi.py b/tests/test_monoprop_mpi.py index ad02d6d0a..0b60ef7ef 100644 --- a/tests/test_monoprop_mpi.py +++ b/tests/test_monoprop_mpi.py @@ -141,7 +141,7 @@ def test_evolved_operator_coefficients_is_rank_local( problem = lih_fermionic_spin_exact parameters = problem.monomial_circuit.parameters - serial, serial_circuit = _make_mp(problem, MPI.COMM_SELF) + serial, serial_circuit = _make_mp(problem, None) serial.build_graph(serial_circuit) # Sorted so every rank queries the same terms in the same order. terms = sorted( @@ -185,7 +185,7 @@ def test_custom_communicator_split(self, lih_fermionic_spin_exact): _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) def test_mpi_comm_self(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_SELF) + mp, circuit = _make_mp(lih_fermionic_spin_exact, None) mp.build_graph(circuit) expval = mp.expval_functional(pare_threshold=1e-10)( lih_fermionic_spin_exact.monomial_circuit.parameters diff --git a/tests/test_monoprop_smoke.py b/tests/test_monoprop_smoke.py index 9176a333d..8b7d2df4f 100644 --- a/tests/test_monoprop_smoke.py +++ b/tests/test_monoprop_smoke.py @@ -19,22 +19,22 @@ from monoprop import MajoranaPropagator, jordan_wigner_basis_change -def _bound_simulator_type(problem, serial_comm): +def _bound_simulator_type(problem): wrapper = MajoranaPropagator( problem.operator, problem.monomial_circuit.initial_state, cutoff=2 * problem.n_modes, - comm=serial_comm, + comm=None, ) return type(wrapper._simulator) -def _make_bound_simulator(bound_type, problem, serial_comm, *, schrodinger: bool): +def _make_bound_simulator(bound_type, problem, *, schrodinger: bool): return bound_type( initial_operator=problem.operator.terms, cutoff=2 * problem.n_modes, initial_state=problem.monomial_circuit.initial_state, - comm=serial_comm, + comm=None, schrodinger_cutoff=2 * problem.n_modes if schrodinger else None, lower_atol=None, upper_atol=None, @@ -43,12 +43,11 @@ def _make_bound_simulator(bound_type, problem, serial_comm, *, schrodinger: bool ) -def _make_bound_core(problem, serial_comm, *, schrodinger: bool): - bound_type = _bound_simulator_type(problem, serial_comm) +def _make_bound_core(problem, *, schrodinger: bool): + bound_type = _bound_simulator_type(problem) return _make_bound_simulator( bound_type, problem, - serial_comm, schrodinger=schrodinger, ) @@ -69,10 +68,8 @@ def _evolve_bound_core(core, monomial_circuit): @pytest.mark.parametrize( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) -def test_bound_constructor_accepts_declared_arguments( - lih_problem, serial_comm, schrodinger -): - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) +def test_bound_constructor_accepts_declared_arguments(lih_problem, schrodinger): + core = _make_bound_core(lih_problem, schrodinger=schrodinger) assert core.num_modes == lih_problem.n_modes assert core.schrodinger is schrodinger @@ -81,10 +78,8 @@ def test_bound_constructor_accepts_declared_arguments( @pytest.mark.parametrize( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) -def test_bound_mutators_accept_declared_arguments( - lih_problem, serial_comm, schrodinger -): - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) +def test_bound_mutators_accept_declared_arguments(lih_problem, schrodinger): + core = _make_bound_core(lih_problem, schrodinger=schrodinger) core.lower_atol = 1e-12 core.lower_atol = None @@ -104,10 +99,10 @@ def test_bound_mutators_accept_declared_arguments( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) def test_bound_expectation_value_methods_accept_declared_arguments( - lih_problem, serial_comm, schrodinger + lih_problem, schrodinger ): monomial_circuit = lih_problem.monomial_circuit - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) + core = _make_bound_core(lih_problem, schrodinger=schrodinger) parameters, coeffs = _evolve_bound_core(core, monomial_circuit) expectation_value_fn = core.expectation_value_functional(pare_threshold=None) @@ -127,11 +122,9 @@ def test_bound_expectation_value_methods_accept_declared_arguments( @pytest.mark.parametrize( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) -def test_bound_graph_methods_accept_declared_arguments( - lih_problem, serial_comm, schrodinger -): +def test_bound_graph_methods_accept_declared_arguments(lih_problem, schrodinger): monomial_circuit = lih_problem.monomial_circuit - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) + core = _make_bound_core(lih_problem, schrodinger=schrodinger) parameters, _ = _evolve_bound_core(core, monomial_circuit) evolved_operator = core.evolved_operator(parameters, 1e-12) @@ -164,10 +157,10 @@ def test_bound_graph_methods_accept_declared_arguments( ) -def test_graph_memory_breakdown_keys_and_totals(lih_problem, serial_comm): +def test_graph_memory_breakdown_keys_and_totals(lih_problem): """``graph_memory_breakdown()`` had no Python-side coverage at all before this.""" monomial_circuit = lih_problem.monomial_circuit - core = _make_bound_core(lih_problem, serial_comm, schrodinger=False) + core = _make_bound_core(lih_problem, schrodinger=False) _evolve_bound_core(core, monomial_circuit) breakdown = core.graph_memory_breakdown() diff --git a/tests/test_monoprop_trivial.py b/tests/test_monoprop_trivial.py index d811f90e3..2fb04030c 100644 --- a/tests/test_monoprop_trivial.py +++ b/tests/test_monoprop_trivial.py @@ -40,7 +40,7 @@ ], ) def test_trivial_evolved_operator_cases( - initial_op, cutoff, schrodinger_cutoff, expected, serial_comm + initial_op, cutoff, schrodinger_cutoff, expected ): kwargs = {"schrodinger_cutoff": schrodinger_cutoff} if schrodinger_cutoff else {} quantum_circuit = Circuit( @@ -50,21 +50,18 @@ def test_trivial_evolved_operator_cases( initial_op, quantum_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, **kwargs, ) result = mp.evolved_operator() assert result.terms == expected.terms -def test_trivial_evolved_operator(serial_comm): +def test_trivial_evolved_operator(): initial_op = MajoranaOperator({(0, 1, 2, 4): 1}, 8) quantum_circuit = Circuit( initial_state=[], system_size=initial_op.num_modes, gates=[] ) - mp = MajoranaPropagator( - initial_op, quantum_circuit.initial_state, cutoff=16, comm=serial_comm - ) + mp = MajoranaPropagator(initial_op, quantum_circuit.initial_state, cutoff=16) op = mp.contract_partially(inplace=False) assert op == np.array([-1.0]) @@ -79,7 +76,7 @@ def test_trivial_evolved_operator(serial_comm): "expval_check", ), [ - # Regular picture: checks contract_partially (rank-local) → serial_comm + # Regular picture: checks contract_partially (rank-local), so serial ( MajoranaOperator({(0, 1, 2, 4): 1}, 8), MajoranaOperator({(0, 1, 2, 4): 2.0 + 0j}, 8), @@ -106,7 +103,6 @@ def test_update_initial_operator( schrodinger_cutoff, expected_new, expval_check, - serial_comm, ): kwargs = {"schrodinger_cutoff": schrodinger_cutoff} if schrodinger_cutoff else {} quantum_circuit = Circuit(initial_state=[], system_size=init_op.num_modes, gates=[]) @@ -114,7 +110,6 @@ def test_update_initial_operator( init_op, quantum_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, **kwargs, ) diff --git a/tests/test_mpi_configuration.py b/tests/test_mpi_configuration.py index 34a6cd886..4f4eab028 100644 --- a/tests/test_mpi_configuration.py +++ b/tests/test_mpi_configuration.py @@ -118,6 +118,19 @@ def test_mpi_configuration_without_terminal_reporter(mpi_config, monkeypatch): mpi_config.pluginmanager.unregister.assert_not_called() +def test_comm_runs_serially_and_on_the_world_only_under_mpi(): + """``comm`` is ``None`` first; its world case exists only with an MPI backend and is ``mpi``-marked.""" + serial, *world = conftest._COMM_PARAMS + assert serial.values == (None,) + assert not serial.marks + if conftest.HAS_MPI_BACKEND: + (param,) = world + assert param.values == ("comm_world",) + assert [mark.name for mark in param.marks] == ["mpi"] + else: + assert world == [] + + @pytest.mark.parametrize( ("size", "given", "expected"), [ diff --git a/tests/test_nonfermi.py b/tests/test_nonfermi.py index e72ec8d60..01fda4a7e 100644 --- a/tests/test_nonfermi.py +++ b/tests/test_nonfermi.py @@ -18,7 +18,7 @@ from monoprop.majorana import MajoranaOperator -def test_nonfermi(serial_comm): +def test_nonfermi(): # Oracle: a hand-computed evolution of a non-fermionic operator. num_modes = 2 majoranas = [(0,), (0, 1), (0, 1, 3), (2, 3)] @@ -52,7 +52,6 @@ def test_nonfermi(serial_comm): fermionic_operator, circuit.initial_state, cutoff=4, - comm=serial_comm, ) mp.propagate(circuit) test_evolved_op = mp.evolved_operator() diff --git a/tests/test_only_rotate_k.py b/tests/test_only_rotate_k.py index 01f1001d1..553f83a69 100644 --- a/tests/test_only_rotate_k.py +++ b/tests/test_only_rotate_k.py @@ -25,9 +25,9 @@ @pytest.fixture -def serial_mp_kwargs(serial_comm): - """Common MP constructor arguments — always single-rank.""" - return {"cutoff": 6, "schrodinger_cutoff": 8, "comm": serial_comm} +def serial_mp_kwargs(): + """Common MP constructor arguments.""" + return {"cutoff": 6, "schrodinger_cutoff": 8} def _split_orbital_gates(gates): @@ -103,7 +103,7 @@ def get_expval(mp): assert np.isclose(expval_none, expval_default, atol=1e-12) -def test_basic_orbital_rotation(serial_comm): +def test_basic_orbital_rotation(): n_modes = 4 operator = MajoranaOperator({}, n_modes) @@ -116,7 +116,7 @@ def test_basic_orbital_rotation(serial_comm): system_size=n_modes, ) circuit = sequence - kwargs = {"cutoff": 6, "schrodinger_cutoff": 8, "comm": serial_comm} + kwargs = {"cutoff": 6, "schrodinger_cutoff": 8} mp_act = MajoranaPropagator(operator, sequence.initial_state, **kwargs) initial_state = mp_act.evolved_operator() @@ -142,7 +142,7 @@ def test_basic_orbital_rotation(serial_comm): @pytest.mark.parametrize("inplace", [False, True]) def test_only_rotate_len_k(orbital_rotation_problem, inplace, serial_mp_kwargs): - """Tests size/graph_size (rank-local) so uses serial_comm.""" + """Serial: size/graph_size are rank-local.""" circuit = orbital_rotation_problem.monomial_circuit.to_circuit() gates = circuit.gates parameters = orbital_rotation_problem.monomial_circuit.parameters diff --git a/tests/test_parameter_validation.py b/tests/test_parameter_validation.py index 9e0c37b91..f024d4e63 100644 --- a/tests/test_parameter_validation.py +++ b/tests/test_parameter_validation.py @@ -23,10 +23,10 @@ from monoprop.pauli import PauliOperator -def _two_gate_graph(serial_comm): +def _two_gate_graph(): """A propagator with a two-layer, two-parameter graph already built.""" operator = MajoranaOperator({(0, 1): 1.0j, (2, 3): 0.5j}, num_modes=2) - mp = MajoranaPropagator(operator, [0, 1], cutoff=4, comm=serial_comm) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4) circuit = Circuit( ( ExpGate(MajoranaOperator({(0,): 1.0}, num_modes=2)), @@ -39,7 +39,7 @@ def _two_gate_graph(serial_comm): class TestConstructorValidation: - def test_invalid_tolerances(self, serial_comm): + def test_invalid_tolerances(self): operator = MajoranaOperator({(0, 1): 1.0j}, num_modes=2) with pytest.raises( RuntimeError, @@ -51,10 +51,9 @@ def test_invalid_tolerances(self, serial_comm): cutoff=4, upper_atol=0.1, lower_atol=0.5, - comm=serial_comm, ) - def test_schrodinger_cutoff_beyond_the_term_index_raises(self, serial_comm): + def test_schrodinger_cutoff_beyond_the_term_index_raises(self): """A Schrodinger cutoff near the mode count admits 2**num_modes paired basis terms. The basis is enumerated, not listed, so an over-wide cutoff is no longer an @@ -72,19 +71,18 @@ def test_schrodinger_cutoff_beyond_the_term_index_raises(self, serial_comm): [], cutoff=4, schrodinger_cutoff=2 * num_modes, - comm=serial_comm, ) class TestGraphAndParameterValidation: - def test_build_graph_accumulates_layers(self, serial_comm): - mp, _ = _two_gate_graph(serial_comm) + def test_build_graph_accumulates_layers(self): + mp, _ = _two_gate_graph() assert mp.graph_layers == 2 assert mp.n_parameters == 2 @pytest.mark.parametrize("parameters", [[1.0], [1.0, 2.0, 3.0]]) - def test_wrong_parameter_length_raises(self, serial_comm, parameters): - mp, _ = _two_gate_graph(serial_comm) + def test_wrong_parameter_length_raises(self, parameters): + mp, _ = _two_gate_graph() with pytest.raises(RuntimeError, match="Parameter length"): mp.expval(parameters) @@ -104,9 +102,9 @@ def test_mixed_param_scheme_rejected(self): with pytest.raises(ValueError, match="every gate must set"): Circuit(gates, 2) - def test_shared_mapping_index_ties_gates(self, serial_comm): + def test_shared_mapping_index_ties_gates(self): operator = MajoranaOperator({(0, 1): 1.0j, (2, 3): 0.5j}, num_modes=2) - mp = MajoranaPropagator(operator, [0, 1], cutoff=4, comm=serial_comm) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4) circuit = Circuit( ( ExpGate(MajoranaOperator({(0,): 1.0}, num_modes=2), index=0), @@ -118,9 +116,9 @@ def test_shared_mapping_index_ties_gates(self, serial_comm): assert mp.graph_layers == 2 assert mp.n_parameters == 1 - def test_functional_invalidated_after_graph_mutation(self, serial_comm): + def test_functional_invalidated_after_graph_mutation(self): operator = MajoranaOperator({(0, 1): 1.0j, (2, 3): 0.5j}, num_modes=2) - mp = MajoranaPropagator(operator, [0, 1], cutoff=4, comm=serial_comm) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4) mp.build_graph( Circuit( ( @@ -186,7 +184,6 @@ def test_functional_invalidated_after_graph_mutation(self, serial_comm): ) def test_functional_invalidated_after_initial_operator_update( self, - serial_comm, propagator_cls, initial_operator, updated_operator, @@ -200,7 +197,6 @@ def test_functional_invalidated_after_initial_operator_update( [0, 1], cutoff=cutoff, schrodinger_cutoff=schrodinger_cutoff, - comm=serial_comm, ) mp.build_graph( Circuit(tuple(ExpGate(generator) for generator in gate_generators), 2) @@ -222,10 +218,8 @@ def test_functional_invalidated_after_initial_operator_update( class TestEvolvedOperatorBothPictures: - def test_schrodinger_returns_state_dict(self, serial_comm): + def test_schrodinger_returns_state_dict(self): operator = MajoranaOperator({(0, 1): 1.0j}, num_modes=2) - mp = MajoranaPropagator( - operator, [0, 1], cutoff=4, schrodinger_cutoff=2, comm=serial_comm - ) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4, schrodinger_cutoff=2) result = mp.evolved_operator() assert isinstance(result, MajoranaOperator) diff --git a/tests/test_pauli.py b/tests/test_pauli.py index a43d5847e..e2b8bdd87 100644 --- a/tests/test_pauli.py +++ b/tests/test_pauli.py @@ -24,28 +24,27 @@ class TestPauliPropagatorCutoff: """PauliPropagator fixes the cutoff to Pauli weight ('support').""" - def _propagator(self, serial_comm): + def _propagator(self): return PauliPropagator( PauliOperator({"ZZ": 1.0}, num_qubits=2), initial_state=[], cutoff=4, - comm=serial_comm, ) - def test_cutoff_type_is_support(self, serial_comm): - assert self._propagator(serial_comm).cutoff_type == "support" + def test_cutoff_type_is_support(self): + assert self._propagator().cutoff_type == "support" - def test_cutoff_type_is_read_only(self, serial_comm): - mp = self._propagator(serial_comm) + def test_cutoff_type_is_read_only(self): + mp = self._propagator() with pytest.raises(AttributeError): mp.cutoff_type = "length" - def test_num_qubits(self, serial_comm): - mp = self._propagator(serial_comm) + def test_num_qubits(self): + mp = self._propagator() assert mp.num_qubits == 2 # "ZZ" operator @pytest.mark.parametrize("schrodinger_cutoff", [3, 4, 5]) - def test_schrodinger_cutoff(self, schrodinger_cutoff, serial_comm): + def test_schrodinger_cutoff(self, schrodinger_cutoff): """schrodinger_cutoff is a Pauli weight in qubits, matching ``cutoff``. PauliPropagator doubles it internally (qubit weight -> slot popcount). @@ -55,7 +54,6 @@ def test_schrodinger_cutoff(self, schrodinger_cutoff, serial_comm): initial_state=[], cutoff=4, schrodinger_cutoff=schrodinger_cutoff, - comm=serial_comm, ) op = mp.evolved_operator() assert max(len(p.qubits) for p in op.terms) == schrodinger_cutoff @@ -73,13 +71,12 @@ def test_schrodinger_cutoff(self, schrodinger_cutoff, serial_comm): pytest.param(PauliOperator({"IZZI": 1.1}, num_qubits=4), id="izzi"), ], ) -def test_evolved_operator_returns_pauli_operator(initial_operator, serial_comm) -> None: +def test_evolved_operator_returns_pauli_operator(initial_operator) -> None: """Without a graph, evolved_operator returns the Pauli-domain initial operator.""" propagator = PauliPropagator( initial_operator, initial_state=[], cutoff=2 * initial_operator.num_qubits, - comm=serial_comm, ) evolved = propagator.evolved_operator() @@ -89,10 +86,10 @@ def test_evolved_operator_returns_pauli_operator(initial_operator, serial_comm) assert evolved.isclose(initial_operator) -def test_update_initial_operator(serial_comm): +def test_update_initial_operator(): """Make sure the initial operator update works correctly with PauliOperator input.""" operator = PauliOperator({"Z": 1.0}, num_qubits=1) - mp = PauliPropagator(operator, initial_state=[], cutoff=1, comm=serial_comm) + mp = PauliPropagator(operator, initial_state=[], cutoff=1) updated_operator = PauliOperator({"Z": 2.75}, num_qubits=1) mp.update_initial_operator(updated_operator) diff --git a/tests/test_term_expectation_values.py b/tests/test_term_expectation_values.py index 5e73cb941..827d56482 100644 --- a/tests/test_term_expectation_values.py +++ b/tests/test_term_expectation_values.py @@ -17,6 +17,9 @@ The values are checked against the expectation value they reassemble, the expectation value with each term alone, and the Schrodinger evolved state, which holds the same values when untruncated. + +Values are allreduced, so the tests run on ``comm``; the Schrodinger read compares rank-local +evolved-state coefficients and stays serial. """ from __future__ import annotations @@ -58,7 +61,7 @@ def _circuit() -> Circuit: return Circuit(tuple(gates), N_QUBITS) -def _heisenberg(cutoff: int, serial_comm, coefficients=None) -> PauliPropagator: +def _heisenberg(cutoff: int, comm, coefficients=None) -> PauliPropagator: """A Heisenberg propagator over ``OBSERVABLES`` with the circuit stored as a graph.""" coefficients = ( np.linspace(0.3, 1.2, len(OBSERVABLES)) @@ -69,7 +72,7 @@ def _heisenberg(cutoff: int, serial_comm, coefficients=None) -> PauliPropagator: PauliOperator(dict(zip(OBSERVABLES, coefficients)), N_QUBITS), initial_state=[0, 3], cutoff=cutoff, - comm=serial_comm, + comm=comm, ) prop.build_graph(_circuit()) return prop @@ -83,10 +86,10 @@ def parameters() -> np.ndarray: @pytest.mark.parametrize("cutoff", [2, 3, N_QUBITS]) def test_pauli_values_reassemble_the_expectation_value( - cutoff, parameters, serial_comm + cutoff, parameters, comm ) -> None: """Initial coefficients times values sum to the expectation value, truncated or not.""" - prop = _heisenberg(cutoff, serial_comm) + prop = _heisenberg(cutoff, comm) values = prop.term_expectation_values(OBSERVABLES, parameters) assert values.dtype == np.float64 @@ -98,11 +101,9 @@ def test_pauli_values_reassemble_the_expectation_value( @pytest.mark.parametrize("cutoff", [2, N_QUBITS]) -def test_each_value_matches_a_single_term_reweight( - cutoff, parameters, serial_comm -) -> None: +def test_each_value_matches_a_single_term_reweight(cutoff, parameters, comm) -> None: """Each value matches the expectation value once the initial operator is that term alone.""" - prop = _heisenberg(cutoff, serial_comm) + prop = _heisenberg(cutoff, comm) values = prop.term_expectation_values(OBSERVABLES, parameters) for i in range(len(OBSERVABLES)): @@ -110,9 +111,9 @@ def test_each_value_matches_a_single_term_reweight( assert prop.expectation_value(parameters) == pytest.approx(values[i], abs=1e-12) -def test_values_match_a_schrodinger_read(parameters, serial_comm) -> None: +def test_values_match_a_schrodinger_read(parameters) -> None: """Untruncated, Heisenberg values equal the Schrodinger evolved-state coefficients.""" - heisenberg = _heisenberg(N_QUBITS, serial_comm).term_expectation_values( + heisenberg = _heisenberg(N_QUBITS, None).term_expectation_values( OBSERVABLES, parameters ) @@ -121,7 +122,6 @@ def test_values_match_a_schrodinger_read(parameters, serial_comm) -> None: initial_state=[0, 3], cutoff=N_QUBITS, schrodinger_cutoff=N_QUBITS, - comm=serial_comm, ) schrodinger.build_graph(_circuit()) state = schrodinger.evolved_operator_coefficients(OBSERVABLES, parameters) @@ -134,22 +134,22 @@ def test_values_match_a_schrodinger_read(parameters, serial_comm) -> None: ) -def test_terms_follow_query_order(parameters, serial_comm) -> None: +def test_terms_follow_query_order(parameters, comm) -> None: """``terms`` reorders and repeats exactly as given.""" - prop = _heisenberg(3, serial_comm) + prop = _heisenberg(3, comm) values = prop.term_expectation_values(OBSERVABLES, parameters) order = [3, 0, 3, 4] queried = prop.term_expectation_values([OBSERVABLES[i] for i in order], parameters) np.testing.assert_array_equal(queried, values[order]) -def test_identity_returns_one(parameters, serial_comm) -> None: +def test_identity_returns_one(parameters, comm) -> None: """The identity returns 1, and its coefficient still counts when reassembling the expectation value.""" prop = PauliPropagator( PauliOperator({Pauli("", ()): 0.5, OBSERVABLES[0]: 1.0}, N_QUBITS), initial_state=[], cutoff=3, - comm=serial_comm, + comm=comm, ) prop.build_graph(_circuit()) values = prop.term_expectation_values([Pauli("", ()), OBSERVABLES[0]], parameters) @@ -159,20 +159,20 @@ def test_identity_returns_one(parameters, serial_comm) -> None: ) -def test_heisenberg_absent_term_raises(parameters, serial_comm) -> None: +def test_heisenberg_absent_term_raises(parameters, comm) -> None: """Heisenberg raises for a term absent from the evolved operator.""" - prop = _heisenberg(2, serial_comm) + prop = _heisenberg(2, comm) terms = [Pauli("XXXXX", (0, 1, 2, 3, 4))] with pytest.raises(RuntimeError, match="not found"): prop.term_expectation_values(terms, parameters) -def test_heisenberg_rejects_absorbed_gates(parameters, serial_comm) -> None: +def test_heisenberg_rejects_absorbed_gates(parameters, comm) -> None: """Heisenberg raises after an in-place contract_partially or propagate. The values would miss the absorbed gates. """ - contracted = _heisenberg(2, serial_comm) + contracted = _heisenberg(2, comm) contracted.contract_partially(parameters, inplace=True) with pytest.raises(RuntimeError, match="absorbed gates"): contracted.term_expectation_values(OBSERVABLES) @@ -181,7 +181,7 @@ def test_heisenberg_rejects_absorbed_gates(parameters, serial_comm) -> None: PauliOperator(dict.fromkeys(OBSERVABLES, 1.0), N_QUBITS), initial_state=[0, 3], cutoff=2, - comm=serial_comm, + comm=comm, ) circuit = _circuit() propagated.propagate(Circuit(circuit.gates, N_QUBITS, parameters=tuple(parameters))) @@ -189,28 +189,28 @@ def test_heisenberg_rejects_absorbed_gates(parameters, serial_comm) -> None: propagated.term_expectation_values(OBSERVABLES) -def test_schrodinger_absent_term_returns_zero(parameters, serial_comm) -> None: +def test_schrodinger_absent_term_returns_zero(parameters, comm) -> None: """Schrodinger returns 0 for a term past the state's cutoff, which the truncated state lacks.""" prop = PauliPropagator( PauliOperator({OBSERVABLES[0]: 1.0}, N_QUBITS), initial_state=[], cutoff=2, schrodinger_cutoff=2, - comm=serial_comm, + comm=comm, ) prop.build_graph(_circuit()) values = prop.term_expectation_values([Pauli("XXXXX", (0, 1, 2, 3, 4))], parameters) np.testing.assert_array_equal(values, [0.0]) -def test_majorana_values_reassemble_the_expectation_value(serial_comm) -> None: +def test_majorana_values_reassemble_the_expectation_value(comm) -> None: """Majorana values are complex, and coefficients times values sum to the expectation value.""" problem = load_problem(DATA / "random_exact.msgpack") prop = MajoranaPropagator( problem.operator, problem.monomial_circuit.initial_state, cutoff=problem.n_modes // 2, - comm=serial_comm, + comm=comm, ) prop.build_graph(problem.monomial_circuit.to_circuit()) parameters = problem.monomial_circuit.parameters diff --git a/tests/test_update_methods.py b/tests/test_update_methods.py index 7e80335c2..d59197241 100644 --- a/tests/test_update_methods.py +++ b/tests/test_update_methods.py @@ -24,14 +24,13 @@ class TestUpdateMethods: @pytest.fixture - def mp(self, serial_comm): + def mp(self): return MajoranaPropagator( initial_operator=MajoranaOperator({(0, 1): 1.0j}, num_modes=4), initial_state=[], cutoff=4, lower_atol=1e-8, upper_atol=1e-4, - comm=serial_comm, ) @pytest.mark.parametrize("valid_value", [1e-10, None]) @@ -132,7 +131,7 @@ def test_update_cutoff_type_enum_validation(self, mp, invalid_value): with pytest.raises((ValueError, RuntimeError, TypeError)): mp.cutoff_type = invalid_value - def test_integration(self, serial_comm): + def test_integration(self): sequence = Circuit.from_dense_arrays( initial_state=[], majoranas=[(0, 2), (1, 3)], @@ -145,7 +144,6 @@ def test_integration(self, serial_comm): initial_operator=MajoranaOperator({(0, 1): 1.0j}, num_modes=4), initial_state=sequence.initial_state, cutoff=4, - comm=serial_comm, ) updates = { "lower_atol": 1e-10, @@ -196,7 +194,7 @@ def test_reset_to_defaults(self, mp): @pytest.mark.parametrize("test_type", ["cutoff", "lower_atol", "upper_atol"]) -def test_evolutions_after_updates(lih_problem, test_type, serial_comm): +def test_evolutions_after_updates(lih_problem, test_type): circuit = lih_problem.monomial_circuit.to_circuit() # upper_atol only rescues over-cutoff partners, of which cutoff 6 produces none here. @@ -206,7 +204,6 @@ def test_evolutions_after_updates(lih_problem, test_type, serial_comm): lih_problem.operator, lih_problem.monomial_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, ) mp.propagate(circuit) mp_size = mp.size() @@ -215,7 +212,6 @@ def test_evolutions_after_updates(lih_problem, test_type, serial_comm): lih_problem.operator, lih_problem.monomial_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, ) if test_type == "lower_atol": mp_tes.lower_atol = 1 From 5e4af55af499f708d9314080f0226791176850a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 12:45:40 +0100 Subject: [PATCH 06/15] =?UTF-8?q?test(py):=20=F0=9F=A5=85=20report=20failu?= =?UTF-8?q?res=20from=20every=20MPI=20rank,=20not=20only=20rank=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- tests/conftest.py | 23 +++++++++++++++++++++++ tests/test_mpi_configuration.py | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index f795919f0..a151a8534 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,7 @@ import getpass import os import stat +import sys import tempfile from pathlib import Path from typing import TYPE_CHECKING @@ -128,6 +129,25 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) +class _RankFailureReporter: + """Writes a muted rank's failures to stderr, tagged with the rank. + + Only rank 0 keeps the terminal reporter, so without this a failure on another rank shows only + as mpiexec's non-zero exit. + """ + + def __init__(self, rank: int) -> None: + self._rank = rank + + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + """Print a failed phase's report.""" + if report.failed: + sys.stderr.write( + f"[rank {self._rank}] FAILED {report.nodeid} ({report.when})\n" + f"{report.longreprtext}\n" + ) + + @pytest.hookimpl(trylast=True) def pytest_configure(config: pytest.Config) -> None: """Enable ``--with-mpi`` under the VS Code adapter, which bypasses @@ -144,6 +164,9 @@ def pytest_configure(config: pytest.Config) -> None: reporter = config.pluginmanager.getplugin("terminalreporter") if reporter is not None: config.pluginmanager.unregister(reporter) + config.pluginmanager.register( + _RankFailureReporter(MPI.COMM_WORLD.rank), "monoprop-rank-failures" + ) @pytest.hookimpl(tryfirst=True) diff --git a/tests/test_mpi_configuration.py b/tests/test_mpi_configuration.py index 4f4eab028..636342181 100644 --- a/tests/test_mpi_configuration.py +++ b/tests/test_mpi_configuration.py @@ -106,8 +106,12 @@ def test_only_rank_zero_reports_under_mpi( mpi_config.pluginmanager.unregister.assert_called_once_with( mpi_config.pluginmanager.getplugin.return_value ) + (plugin, name), _ = mpi_config.pluginmanager.register.call_args + assert isinstance(plugin, conftest._RankFailureReporter) + assert name == "monoprop-rank-failures" else: mpi_config.pluginmanager.unregister.assert_not_called() + mpi_config.pluginmanager.register.assert_not_called() def test_mpi_configuration_without_terminal_reporter(mpi_config, monkeypatch): @@ -116,6 +120,26 @@ def test_mpi_configuration_without_terminal_reporter(mpi_config, monkeypatch): mpi_config.pluginmanager.getplugin.return_value = None conftest.pytest_configure(mpi_config) mpi_config.pluginmanager.unregister.assert_not_called() + mpi_config.pluginmanager.register.assert_called_once() + + +def test_muted_rank_reports_its_failures_on_stderr(capsys): + reporter = conftest._RankFailureReporter(rank=3) + report = Mock( + failed=True, + nodeid="tests/test_x.py::test_y", + when="call", + longreprtext="AssertionError: boom", + ) + reporter.pytest_runtest_logreport(report) + err = capsys.readouterr().err + assert "[rank 3] FAILED tests/test_x.py::test_y (call)" in err + assert "AssertionError: boom" in err + + +def test_muted_rank_is_quiet_on_success(capsys): + conftest._RankFailureReporter(rank=1).pytest_runtest_logreport(Mock(failed=False)) + assert capsys.readouterr().err == "" def test_comm_runs_serially_and_on_the_world_only_under_mpi(): From cc2449c5f6eb91a52626e496f8a2c4245dd26ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 12:50:14 +0100 Subject: [PATCH 07/15] =?UTF-8?q?test(py):=20=E2=9C=85=20compare=20distrib?= =?UTF-8?q?uted=20Majorana=20and=20Pauli=20runs=20with=20the=20serial=20ru?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- tests/test_infinite_cutoff.py | 8 +- tests/test_monoprop_mpi.py | 350 +++++++++++++++++++++------------- tests/test_monoprop_smoke.py | 11 +- 3 files changed, 221 insertions(+), 148 deletions(-) diff --git a/tests/test_infinite_cutoff.py b/tests/test_infinite_cutoff.py index 13bdc2628..ebcf30de1 100644 --- a/tests/test_infinite_cutoff.py +++ b/tests/test_infinite_cutoff.py @@ -35,7 +35,7 @@ def _create_mp(problem, comm, *, schrodinger_cutoff=None): @pytest.mark.parametrize("pare_threshold", [None, 1e-10]) @pytest.mark.parametrize("schrodinger_cutoff", [None, "max"]) -@pytest.mark.parametrize("evolution_mode", ["deferred", "with_coeffs", "inplace"]) +@pytest.mark.parametrize("evolution_mode", ["deferred", "inplace"]) def test_infinite_cutoff( problem, pare_threshold, schrodinger_cutoff, evolution_mode, comm ): @@ -50,12 +50,6 @@ def test_infinite_cutoff( parameters ) - case "with_coeffs": - mp.build_graph(circuit) - test_expval = mp.expval_functional(pare_threshold=pare_threshold)( - parameters - ) - case "inplace": mp.propagate(circuit) test_expval = mp.expval_functional(pare_threshold=pare_threshold)() diff --git a/tests/test_monoprop_mpi.py b/tests/test_monoprop_mpi.py index 0b60ef7ef..6a9493124 100644 --- a/tests/test_monoprop_mpi.py +++ b/tests/test_monoprop_mpi.py @@ -12,45 +12,100 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""MPI tests for the Majorana Propagator. +"""Distributed propagation, checked against exact values or the same propagation run serially. -Run with: mpirun -np uv run pytest tests/test_monoprop_mpi.py --with-mpi -m mpi -v +Every test carries the ``mpi`` marker and runs on ``comm_world``, the session's duplicate of +COMM_WORLD. ``just test-py-mpi`` runs them in each configuration CI covers; by hand: + + mpiexec -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi """ from __future__ import annotations +import os + import numpy as np import pytest +from monoprop import Circuit, ExpGate, MajoranaPropagator, PauliPropagator +from monoprop.pauli import Pauli, PauliOperator from tests.reference_data import load_problem MPI = pytest.importorskip("mpi4py").MPI -from monoprop import MajoranaPropagator # noqa: E402 +pytestmark = pytest.mark.mpi + +# The C++ suite's near(): a reduction sums in an order that depends on the rank count. +RTOL = 1e-7 +ATOL = 1e-9 + +# random_exact's observable has only terms of length >= 10: a lower cutoff leaves the energy 0 on +# every rank count, which cannot tell a broken exchange apart. +MAJORANA_CUTOFF = 10 +PAULI_CUTOFF = 3 +N_QUBITS = 6 +PAULI_OBSERVABLES = ( + Pauli("ZZ", (0, 1)), + Pauli("XY", (2, 3)), + Pauli("Z", (4,)), + Pauli("YX", (1, 5)), + Pauli("ZZ", (2, 4)), +) @pytest.fixture -def lih_fermionic_spin_exact(lazy_shared_datadir): - data_path = lazy_shared_datadir / "lih_fermionic_spin_exact.msgpack" - return load_problem(data_path) +def random_exact(lazy_shared_datadir): + return load_problem(lazy_shared_datadir / "random_exact.msgpack") -def _make_mp(problem, comm, *, schrodinger=False): - mp = MajoranaPropagator( +def _majorana(problem, comm, *, cutoff=None, schrodinger=False): + cutoff = 2 * problem.n_modes if cutoff is None else cutoff + propagator = MajoranaPropagator( problem.operator, problem.monomial_circuit.initial_state, - cutoff=2 * problem.n_modes, - schrodinger_cutoff=2 * problem.n_modes if schrodinger else None, + cutoff=cutoff, + schrodinger_cutoff=cutoff if schrodinger else None, + comm=comm, + ) + return propagator, problem.monomial_circuit.to_circuit() + + +def _pauli_circuit() -> Circuit: + """Rotations about each axis between nearest-neighbour ZZ layers, enough to spread every observable.""" + gates = [] + for layer in range(3): + axis = "XYZ"[layer % 3] + gates += [ + ExpGate(PauliOperator({Pauli(axis, (q,)): 1.0}, N_QUBITS)) + for q in range(N_QUBITS) + ] + gates += [ + ExpGate(PauliOperator({Pauli("ZZ", (q, q + 1)): 1.0}, N_QUBITS)) + for q in range(layer % 2, N_QUBITS - 1, 2) + ] + return Circuit(tuple(gates), N_QUBITS) + + +def _pauli(comm, *, cutoff, schrodinger): + """A Pauli propagator over ``PAULI_OBSERVABLES`` with the circuit built, and its angles.""" + circuit = _pauli_circuit() + weights = np.linspace(0.3, 1.2, len(PAULI_OBSERVABLES)) + propagator = PauliPropagator( + PauliOperator(dict(zip(PAULI_OBSERVABLES, weights)), N_QUBITS), + initial_state=[0, 3], + cutoff=cutoff, + schrodinger_cutoff=cutoff if schrodinger else None, comm=comm, ) - circuit = problem.monomial_circuit.to_circuit() - return mp, circuit + propagator.build_graph(circuit) + return propagator, np.random.default_rng(7).uniform( + 0, 2 * np.pi, circuit.n_parameters + ) -def _assert_expval(expval, expected, atol=1e-9): - assert np.isclose(expval, expected, atol=atol), ( - f"Expectation value mismatch: {expval} vs {expected}" - ) +def _pauli_energy(comm, *, cutoff=PAULI_CUTOFF, schrodinger=False) -> float: + propagator, parameters = _pauli(comm, cutoff=cutoff, schrodinger=schrodinger) + return propagator.expectation_value(parameters) def _finite_difference_gradient(expval_fn, parameters, eps=1e-6): @@ -64,130 +119,153 @@ def _finite_difference_gradient(expval_fn, parameters, eps=1e-6): return gradient -@pytest.mark.mpi -class TestMPISimulator: - def test_evolve_expectation_value_functional(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters - ) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_evolve_build_graph_with_coeffs(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - parameters = lih_fermionic_spin_exact.monomial_circuit.parameters - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)(parameters) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_immediate_contraction(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.propagate(circuit) - _assert_expval( - mp.expval_functional(pare_threshold=1e-10)(), - lih_fermionic_spin_exact.exact_expval, - ) - - def test_schrodinger_picture(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp( - lih_fermionic_spin_exact, MPI.COMM_WORLD, schrodinger=True - ) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters - ) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_gradient(self, lih_fermionic_spin_exact): - """Check the analytic gradient against central finite differences.""" - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.build_graph(circuit) - - expval_fn = mp.expval_functional(pare_threshold=1e-10) - grad_fn = mp.expval_and_grad_functional(pare_threshold=1e-10) - - rng = np.random.default_rng(42) - xk = rng.random(size=len(lih_fermionic_spin_exact.monomial_circuit.parameters)) - - _, analytical_grad = grad_fn(xk) - fd_gradient = _finite_difference_gradient(expval_fn, xk) - - assert np.allclose(analytical_grad, fd_gradient, atol=1e-9) +def test_expval_functional_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.build_graph(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)( + lih_problem.monomial_circuit.parameters + ) + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) - def test_expectation_value_and_gradient_functional(self, lih_fermionic_spin_exact): - """Combined expectation value + gradient is consistent and matches exact.""" - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.build_graph(circuit) - parameters = lih_fermionic_spin_exact.monomial_circuit.parameters - expval_grad_fn = mp.expval_and_grad_functional(pare_threshold=1e-10) - expval_fn = mp.expval_functional(pare_threshold=1e-10) +def test_immediate_contraction_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.propagate(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)() + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) - combined_expval, combined_grad = expval_grad_fn(parameters) - individual_expval = expval_fn(parameters) - assert np.isclose(combined_expval, individual_expval, atol=1e-12) - _assert_expval(combined_expval, lih_fermionic_spin_exact.exact_expval) +def test_schrodinger_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world, schrodinger=True) + propagator.build_graph(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)( + lih_problem.monomial_circuit.parameters + ) + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) + + +def test_gradient_matches_finite_differences(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.build_graph(circuit) + expval_fn = propagator.expval_functional(pare_threshold=1e-10) + grad_fn = propagator.expval_and_grad_functional(pare_threshold=1e-10) + rng = np.random.default_rng(42) + parameters = rng.random(size=len(lih_problem.monomial_circuit.parameters)) + _, gradient = grad_fn(parameters) + assert np.allclose( + gradient, _finite_difference_gradient(expval_fn, parameters), atol=1e-9 + ) - assert np.allclose( - combined_grad, lih_fermionic_spin_exact.exact_gradient, atol=1e-9 - ) - def test_evolved_operator_coefficients_is_rank_local( - self, lih_fermionic_spin_exact - ): - """Each rank answers for the terms it owns, and the ranks' answers sum to the serial result.""" - problem = lih_fermionic_spin_exact - parameters = problem.monomial_circuit.parameters - - serial, serial_circuit = _make_mp(problem, None) - serial.build_graph(serial_circuit) - # Sorted so every rank queries the same terms in the same order. - terms = sorted( - t for t in serial.evolved_operator(parameters, atol=0.0).terms if t - ) - assert terms - expected = serial.evolved_operator_coefficients(terms, parameters) - - world, world_circuit = _make_mp(problem, MPI.COMM_WORLD) - world.build_graph(world_circuit) - local = world.evolved_operator_coefficients(terms, parameters) - - summed = np.zeros_like(local) - MPI.COMM_WORLD.Allreduce(local, summed, op=MPI.SUM) - - np.testing.assert_allclose(summed, expected, atol=1e-9) - - if MPI.COMM_WORLD.size > 1: - # Counted against the terms each rank *owns* rather than against its nonzero - # coefficients. - local_terms = world.evolved_operator(parameters, atol=0.0).terms - owned = sum(1 for t in local_terms if t) # the identity is on every rank - busiest = MPI.COMM_WORLD.allreduce(owned, op=MPI.MAX) - assert busiest < len(terms) - - @pytest.mark.mpi(min_size=2) - def test_custom_communicator_split(self, lih_fermionic_spin_exact): - rank = MPI.COMM_WORLD.Get_rank() - sub_comm = MPI.COMM_WORLD.Split(color=rank % 2, key=rank) - - try: - mp, circuit = _make_mp(lih_fermionic_spin_exact, sub_comm) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters - ) - finally: - mp = None - sub_comm.Free() - - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_mpi_comm_self(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, None) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters +def test_expval_and_grad_functional_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.build_graph(circuit) + parameters = lih_problem.monomial_circuit.parameters + combined_expval, combined_grad = propagator.expval_and_grad_functional( + pare_threshold=1e-10 + )(parameters) + individual_expval = propagator.expval_functional(pare_threshold=1e-10)(parameters) + assert np.isclose(combined_expval, individual_expval, atol=1e-12) + assert np.isclose(combined_expval, lih_problem.exact_expval, atol=1e-9) + assert np.allclose(combined_grad, lih_problem.exact_gradient, atol=1e-9) + + +def test_truncated_majorana_matches_serial(random_exact, comm_world): + """A truncated evolution distributes to the serial energy and gradient.""" + parameters = random_exact.monomial_circuit.parameters + results = [] + for comm in (None, comm_world): + propagator, circuit = _majorana(random_exact, comm, cutoff=MAJORANA_CUTOFF) + propagator.build_graph(circuit) + results.append(propagator.expectation_value_and_gradient(parameters)) + (serial_energy, serial_gradient), (world_energy, world_gradient) = results + assert abs(serial_energy) > 1e-6 + assert not np.isclose(serial_energy, random_exact.exact_expval, atol=1e-6) + assert np.isclose(world_energy, serial_energy, rtol=RTOL, atol=ATOL) + np.testing.assert_allclose(world_gradient, serial_gradient, rtol=RTOL, atol=ATOL) + + +@pytest.mark.parametrize( + "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] +) +def test_truncated_pauli_matches_serial(schrodinger, comm_world): + serial = _pauli_energy(None, schrodinger=schrodinger) + assert not np.isclose(serial, _pauli_energy(None, cutoff=N_QUBITS), atol=1e-6) + world = _pauli_energy(comm_world, schrodinger=schrodinger) + assert np.isclose(world, serial, rtol=RTOL, atol=ATOL) + + +def test_update_initial_operator_matches_serial(comm_world): + """Re-weighting reaches the terms each rank owns, so the distributed energy follows the serial one.""" + reweighted = PauliOperator( + dict(zip(PAULI_OBSERVABLES, np.linspace(1.2, 0.3, len(PAULI_OBSERVABLES)))), + N_QUBITS, + ) + energies = [] + for comm in (None, comm_world): + propagator, parameters = _pauli(comm, cutoff=PAULI_CUTOFF, schrodinger=False) + propagator.update_initial_operator(reweighted) + energies.append(propagator.expectation_value(parameters)) + serial, world = energies + assert not np.isclose(serial, _pauli_energy(None), atol=1e-6) + assert np.isclose(world, serial, rtol=RTOL, atol=ATOL) + + +def test_evolved_operator_coefficients_is_rank_local(lih_problem, comm_world): + """Each rank answers for the terms it owns, and the ranks' answers sum to the serial result.""" + parameters = lih_problem.monomial_circuit.parameters + + serial, serial_circuit = _majorana(lih_problem, None) + serial.build_graph(serial_circuit) + # Sorted so every rank queries the same terms in the same order. + terms = sorted(t for t in serial.evolved_operator(parameters, atol=0.0).terms if t) + assert terms + expected = serial.evolved_operator_coefficients(terms, parameters) + + world, world_circuit = _majorana(lih_problem, comm_world) + world.build_graph(world_circuit) + local = world.evolved_operator_coefficients(terms, parameters) + + summed = np.zeros_like(local) + comm_world.Allreduce(local, summed, op=MPI.SUM) + np.testing.assert_allclose(summed, expected, atol=1e-9) + + if comm_world.size > 1: + # Counted against the terms each rank *owns* rather than against its nonzero coefficients. + local_terms = world.evolved_operator(parameters, atol=0.0).terms + owned = sum(1 for t in local_terms if t) # the identity is on every rank + busiest = comm_world.allreduce(owned, op=MPI.MAX) + assert busiest < len(terms) + + +@pytest.mark.mpi(min_size=4) +def test_split_communicators_each_match_exact(lih_problem, comm_world): + """Two halves of the world propagate independently; each half holds at least two ranks.""" + sub_comm = comm_world.Split(color=comm_world.rank % 2, key=comm_world.rank) + propagator = None + try: + propagator, circuit = _majorana(lih_problem, sub_comm) + propagator.build_graph(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)( + lih_problem.monomial_circuit.parameters ) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) + finally: + propagator = None # the communicator must outlive the propagator + sub_comm.Free() + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) + + +@pytest.mark.mpi(min_size=3) +def test_linear_routing_refuses_a_three_rank_communicator(random_exact, comm_world): + """Linear routing XORs rank indices, so construction refuses a rank count that is not a power of two.""" + if os.environ.get("monoprop_ROUTING", "") not in ("", "linear"): # noqa: SIM112 + pytest.skip("monoprop_ROUTING selects splitmix, which routes any rank count") + color = 0 if comm_world.rank < 3 else MPI.UNDEFINED + three_ranks = comm_world.Split(color=color, key=comm_world.rank) + if three_ranks == MPI.COMM_NULL: + return # outside the three-rank communicator there is nothing to construct + try: + with pytest.raises(RuntimeError, match="power-of-two rank count"): + _majorana(random_exact, three_ranks) + finally: + three_ranks.Free() diff --git a/tests/test_monoprop_smoke.py b/tests/test_monoprop_smoke.py index 8b7d2df4f..7f42b01ab 100644 --- a/tests/test_monoprop_smoke.py +++ b/tests/test_monoprop_smoke.py @@ -157,8 +157,11 @@ def test_bound_graph_methods_accept_declared_arguments(lih_problem, schrodinger) ) -def test_graph_memory_breakdown_keys_and_totals(lih_problem): +@pytest.mark.parametrize("partitions", [1, 2]) +def test_graph_memory_breakdown_keys_and_totals(lih_problem, partitions, monkeypatch): """``graph_memory_breakdown()`` had no Python-side coverage at all before this.""" + # Read when the propagator is built: the run's flat world is P = partitions. + monkeypatch.setenv("monoprop_PARTITIONS", str(partitions)) monomial_circuit = lih_problem.monomial_circuit core = _make_bound_core(lih_problem, schrodinger=False) _evolve_bound_core(core, monomial_circuit) @@ -182,11 +185,9 @@ def test_graph_memory_breakdown_keys_and_totals(lih_problem): # deleted so an A/B against a build that retained them shows the drop instead of losing the row. assert breakdown["exchange_layout_bytes"] == 0 - # The slot array is indexed by the flat world, so slot_records / layer_cores recovers P. Stated as - # divisibility, not `== 1`, because the MPI gate also runs this at 16 partitions per rank. + # The slot array is indexed by the flat world, so slot_records / layer_cores is P. assert breakdown["d_layer_cores"] > 0 - assert breakdown["d_slot_records"] % breakdown["d_layer_cores"] == 0 - assert breakdown["d_slot_records"] // breakdown["d_layer_cores"] >= 1 + assert breakdown["d_slot_records"] == partitions * breakdown["d_layer_cores"] # An occupied slot holds at least one endpoint, so the endpoint count is the P-independent ceiling. assert breakdown["d_occupied_slots"] <= breakdown["d_slot_records"] From 92958f333825d7ef0e674e7bbd3e0d16c5dec6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 13:06:12 +0100 Subject: [PATCH 08/15] =?UTF-8?q?build(just):=20=F0=9F=94=A7=20run=20the?= =?UTF-8?q?=20Python=20MPI=20legs=20in=20every=20routing=20and=20partition?= =?UTF-8?q?=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- justfile | 58 +++++++++++++++++++++++------------- pyproject.toml | 1 + tools/cibw/test-wheel-mpi.sh | 2 +- uv.lock | 18 +++++++++++ 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/justfile b/justfile index 30cfcd274..93ed1e592 100644 --- a/justfile +++ b/justfile @@ -89,28 +89,45 @@ test-cpp-mpi LABEL='': ctest --test-dir {{ mpi_build_dir }} --output-on-failure --no-tests=error --label-regex mpi \ {{ if LABEL == '' { '' } else { '--output-junit ctest-mpi-' + LABEL + '.xml' } }} -# RANKS is a single integer or a semicolon-separated list (e.g. "1;2;4"); the suite runs -# once per entry. Extra arguments go to pytest, e.g. `just test-py-mpi "1;2;4" -m mpi`. +# The Python MPI legs: every test that runs on a world communicator (`-m mpi`), under mpiexec, in +# each configuration CI covers. Linear routing refuses 3 ranks, so 3 runs with splitmix; 2 ranks of 2 +# partitions each make the hybrid ranks x partitions world. A hung collective fails its test after +# 300 s. LABEL, when given, names the JUnit reports, which rank 0 alone writes. -test-py-mpi RANKS='' *PYTEST_ARGS: +test-py-mpi LABEL='': #!/usr/bin/env bash set -euo pipefail - shift 1 - requested_ranks={{ quote(RANKS) }} - ranks="${requested_ranks:-${monoprop_MPI_TEST_PROCS:-2}}" - for r in ${ranks//;/ }; do - echo "Running the Python test suite with ${r} MPI rank(s)" - {{ mpiexec }} -n "$r" uv run --no-sync pytest tests --with-mpi -v "$@" - done - -# Build MPI-enabled, then run every leg. The C++ -# rank matrix is a build-time setting. The MPI switch is local to the build line, so the -# C++ legs are pointed at the MPI tree explicitly. - -test-mpi RANKS='': && (test-py-mpi RANKS) (test-cpp '' mpi_build_dir) test-cpp-mpi - requested_ranks={{ quote(RANKS) }}; \ - ranks="${requested_ranks:-${monoprop_MPI_TEST_PROCS:-2}}"; \ - monoprop_ENABLE_MPI=ON monoprop_MPI_TEST_PROCS="$ranks" {{ uv_sync }} --group workspace-test + label={{ quote(LABEL) }} + leg() { + local name="$1" ranks="$2" + shift 2 + local report="" + if [[ -n "$label" ]]; then + report="pytest-mpi-$label-$name.xml" + fi + echo "Python MPI leg $name: $ranks rank(s)${*:+, $*}" + # OMPI_COMM_WORLD_RANK is Open MPI's rank variable, PMI_RANK the MPICH family's. + env "$@" {{ mpiexec }} -n "$ranks" bash -c ' + report="$1" + shift + rank="${OMPI_COMM_WORLD_RANK:-${PMI_RANK:-0}}" + if [[ -n "$report" && "$rank" == 0 ]]; then + set -- --junit-xml="$report" -o junit_family=legacy "$@" + fi + exec uv run --no-sync pytest "$@" + ' bash "$report" tests --with-mpi -m mpi -v --timeout=300 --timeout-method=thread + } + leg ranks1 1 + leg ranks2 2 + leg ranks4 4 + leg ranks3-splitmix 3 monoprop_ROUTING=splitmix + leg ranks2-partitions2 2 monoprop_PARTITIONS=2 + +# Build MPI-enabled, then run every MPI leg CI runs. The C++ rank counts are passed because an MPI +# build tree caches them, and an older version of this recipe cached `2` alone. + +test-mpi: && test-py-mpi (test-cpp '' mpi_build_dir) test-cpp-mpi + monoprop_ENABLE_MPI=ON monoprop_MPI_TEST_PROCS="2;4" {{ uv_sync }} --group workspace-test # Build and run a consumer project against the installed package: serially, then, when the # installation ships MPI ABIs, through the MPI component under mpiexec, and finally against a @@ -266,7 +283,8 @@ code-coverage-collect MPI BUILD_DIR OUTPUT_DIR: export OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1 {{ mpiexec }} -n 2 \ uv run --no-sync coverage run --parallel-mode \ - -m pytest tests --with-mpi -m mpi + -m pytest tests --with-mpi -m mpi \ + --timeout=300 --timeout-method=thread fi gcovr_args=( diff --git a/pyproject.toml b/pyproject.toml index 0d8364a59..177a3019c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ test = [ "psutil>=7.2", "pytest-cov>=7.1.0", "pytest-datadir", + "pytest-timeout>=2.3", "pytest>=8.2.2,<9.2", "tabulate>=0.9", ] diff --git a/tools/cibw/test-wheel-mpi.sh b/tools/cibw/test-wheel-mpi.sh index af886dc57..b0fa8c0ba 100755 --- a/tools/cibw/test-wheel-mpi.sh +++ b/tools/cibw/test-wheel-mpi.sh @@ -65,5 +65,5 @@ assert monoprop.MPI_ABIS, 'monoprop reports no MPI ABIs' from monoprop._backend import core_module print('MPI_ABIS', monoprop.MPI_ABIS, core_module(MPI.COMM_SELF).__file__) " -"$mpiexec" -n 2 "$python" -m pytest tests --with-mpi -m mpi -p no:cacheprovider +"$mpiexec" -n 2 "$python" -m pytest tests --with-mpi -m mpi -p no:cacheprovider --timeout=300 --timeout-method=thread "$python" -m pytest tests/test_mpi_backend.py -p no:cacheprovider diff --git a/uv.lock b/uv.lock index 2c8885afb..1303048f6 100644 --- a/uv.lock +++ b/uv.lock @@ -1305,6 +1305,7 @@ bench = [ { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, + { name = "pytest-timeout" }, { name = "tabulate" }, ] dev = [ @@ -1334,6 +1335,7 @@ test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, + { name = "pytest-timeout" }, { name = "tabulate" }, ] workspace-test = [ @@ -1343,6 +1345,7 @@ workspace-test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, + { name = "pytest-timeout" }, { name = "tabulate" }, ] @@ -1365,6 +1368,7 @@ bench = [ { name = "pytest-benchmark", specifier = ">=5" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "tabulate", specifier = ">=0.9" }, ] dev = [ @@ -1394,6 +1398,7 @@ test = [ { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "tabulate", specifier = ">=0.9" }, ] workspace-test = [ @@ -1403,6 +1408,7 @@ workspace-test = [ { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "tabulate", specifier = ">=0.9" }, ] @@ -2212,6 +2218,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/b9/c3df11997d29e69b3f8edae1e903bf44eaf4774ccf4c5b6ddcebde88931c/pytest_markdown_docs-0.9.2-py3-none-any.whl", hash = "sha256:9c05a5bee48214cb36583d4a5131d3a23b327a8d82c92ae860106053fd7d1f9e", size = 13536, upload-time = "2026-03-23T12:35:03.862Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dateutil" version = "3.0.0" From 7b1c6490a5150191a6925868692618f98534209e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 13:08:21 +0100 Subject: [PATCH 09/15] =?UTF-8?q?ci(test):=20=F0=9F=91=B7=20run=20the=20MP?= =?UTF-8?q?I=20legs=20through=20the=20recipes,=20with=20a=20job=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- .github/workflows/test.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd71052d5..8db29c582 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,8 @@ jobs: mpi: "on" runs-on: ${{ matrix.runner}} + # A lane takes about 15 minutes; the per-test timeouts catch a hang first, this only bounds it. + timeout-minutes: 60 env: monoprop_ENABLE_MPI: "${{ matrix.mpi }}" @@ -114,11 +116,7 @@ jobs: - name: Run Python tests under MPI if: matrix.mpi == 'on' - run: just test-py-mpi 2 - - - name: Run Python MPI marker rank matrix - if: matrix.mpi == 'on' - run: just test-py-mpi "1;2;4" -m mpi + run: just test-py-mpi "${{ steps.test-group-label.outputs.label }}" - name: Upload serial test results to Codecov uses: codecov/codecov-action@v7.1.1 @@ -129,12 +127,18 @@ jobs: disable_search: true fail_ci_if_error: true - - name: Upload MPI C++ test results to Codecov + - name: Upload MPI test results to Codecov if: matrix.mpi == 'on' uses: codecov/codecov-action@v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ctest-mpi-${{ steps.test-group-label.outputs.label }}.xml + files: >- + ctest-mpi-${{ steps.test-group-label.outputs.label }}.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks1.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks2.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks4.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks3-splitmix.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks2-partitions2.xml report_type: test_results disable_search: true fail_ci_if_error: true From 05cc05aff483fe65091b723089bb73b7460c3bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 13:10:25 +0100 Subject: [PATCH 10/15] =?UTF-8?q?docs(mpi):=20=F0=9F=93=9D=20document=20co?= =?UTF-8?q?mm=3DNone=20and=20the=20MPI=20test=20configurations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- cpp/tests/README.md | 26 +++++++----- docs/content/docs/features/parallelism.mdx | 11 +++-- docs/content/docs/testing.mdx | 47 ++++++++++++++-------- tools/vscode-pytest-wrapper.sh | 4 +- 4 files changed, 56 insertions(+), 32 deletions(-) diff --git a/cpp/tests/README.md b/cpp/tests/README.md index f33e03b16..bc125b3dd 100644 --- a/cpp/tests/README.md +++ b/cpp/tests/README.md @@ -10,11 +10,14 @@ discovers every Boost.Test case and registers it twice: - **`serial`**: the case run in-process with `MPI_COMM_SELF`. - **`mpi`** (+ rank-specific `mpi-`): the whole suite wrapped in - `mpiexec -n ` for each rank in `monoprop_MPI_TEST_PROCS` (default `2;4`), - registered when an MPI launcher is detected. + `mpiexec -n ` for each rank in `monoprop_MPI_TEST_PROCS` (default `2;4`, + also labelled `mpi-linear`), plus splitmix-routing entries for each rank in + `monoprop_MPI_SPLITMIX_TEST_PROCS` (default `2;3;4`, also labelled + `mpi-splitmix`), registered when an MPI launcher is detected. -Cases that need multiple ranks check `monoprop::mpi::size(MPI_COMM_WORLD)` and -skip (with a message) when run with too few. +Cases that need a particular world declare it with a precondition from +`RankPreconditions.h`; Boost reports them as skipped elsewhere, and CTest marks +those serial entries as skipped. The custom `main()` in `unit_tests.cpp` initializes MPI and forces `monoprop_PARTITIONS=off`, so white-box tests observe the single-partition engine. @@ -77,6 +80,8 @@ name and cannot address suite-nested cases, tests use flat - **`dense_query_reference.h`**: the retired dense query record, frozen as the independent oracle for `sparse_query_tests.cpp`. Test-only, and not kept in sync with the wire format. +- **`RankPreconditions.h`**: Boost preconditions `min_ranks`, `power_of_two_ranks`, + `even_ranks`, `linear_routing`, `default_routing`. - **`TestData.{h,cpp}`**: the `CaseData` struct and msgpack fixture loader. - **`boost-test.cmake` / `boostAddTests.cmake`**: CMake test discovery. @@ -117,7 +122,9 @@ name and cannot address suite-nested cases, tests use flat (MPI-only), `partition_equivalence_tests.cpp`, `mpi_distributed_layer_equivalence.cpp`, `mpi_fresh_insert_equivalence.cpp` (serial↔world equivalence of the Schrödinger fused-resolve fresh-insert arms, - Majorana + native Pauli; self-skips at world size 1). + Majorana + native Pauli; self-skips at world size 1), + `mpi_routing_geometry_tests.cpp` (linear routing refuses a 3-rank + communicator). - **Simulator / operator lifecycle**: `simulator_copy_tests.cpp`, `update_initial_operator.cpp`, `ctor_validation_tests.cpp` (constructor guard rails + MPGraph bounds). @@ -134,9 +141,7 @@ CMake wraps the whole suite in `mpiexec -n ` for each rank in `monoprop_MPI_TEST_PROCS` (default `2;4`) — one CTest entry per rank count, not per case, because the ranks have to reach the same collectives. Each entry has a 600-second timeout, so a collective deadlock fails instead of occupying the -runner indefinitely. For exhaustive -rank coverage, run `just test-mpi '1;2;4'` or configure -`-Dmonoprop_MPI_TEST_PROCS='1;2;4'`. To run a single case under MPI while +runner indefinitely. `just test-mpi` runs the same entries CI does. To run a single case under MPI while debugging, invoke the binary directly: `mpirun -n 2 build/editable/Release-mpi/bin/monoprop_unit_tests.x --run_test=`. @@ -144,6 +149,7 @@ debugging, invoke the binary directly: 1. Add a `*.cpp` with flat `BOOST_AUTO_TEST_CASE`s (shared name prefix). 2. Reuse the shared helpers above rather than copying oracle/harness code. -3. For MPI-required scenarios, check `monoprop::mpi::size(MPI_COMM_WORLD)` and - skip if `< 2`. +3. For a case that needs several ranks, add a precondition: + `BOOST_AUTO_TEST_CASE(name, *test_utils::min_ranks(2))`. Never `return` early: + that passes without checking anything. 4. Rebuild to register the new cases with CTest. diff --git a/docs/content/docs/features/parallelism.mdx b/docs/content/docs/features/parallelism.mdx index 0a4d493d4..c135ae733 100644 --- a/docs/content/docs/features/parallelism.mdx +++ b/docs/content/docs/features/parallelism.mdx @@ -112,7 +112,7 @@ exchange, so it is checked collectively at startup and raised. monoprop picks the backend per propagator. `comm=None` runs serially, whatever the installation; an mpi4py communicator runs on the MPI backend, which monoprop loads the first time it is needed. Importing monoprop never imports mpi4py or initializes MPI. Under `mpiexec`, a propagator built -without `comm` therefore runs serially on every rank: pass `comm=MPI.COMM_WORLD` to distribute it. +without `comm` therefore runs serially on every rank: pass a duplicate of `MPI.COMM_WORLD` to distribute it. [MPI_ABIS][monoprop.MPI_ABIS] lists the MPI ABI families the installation can drive; it is empty without MPI support. The MPI backend matches the family of the MPI library mpi4py uses. Passing a @@ -122,11 +122,14 @@ built for raises [MPIAbiMismatchError][monoprop.exceptions.MPIAbiMismatchError], library older than the supported versions (see [Enabling MPI](#enabling-mpi)) raises [MPIVersionError][monoprop.exceptions.MPIVersionError]. -### Single-node (`MPI.COMM_SELF`) +### Single process (`comm=None`) + +Leave `comm` out, or pass `None`, to run in one process. That uses the serial backend, which +needs neither mpi4py nor an MPI library, and still partitions the operator across the cores (see +[Operator partitioning](#operator-partitioning-single-node)). ```python notest -from mpi4py import MPI -sim = MajoranaPropagator(..., comm=MPI.COMM_SELF) +sim = MajoranaPropagator(...) # comm=None ``` ### Multi-node (`MPI.COMM_WORLD`) diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index 9914593ef..e0dfd4d74 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -36,14 +36,25 @@ produce. The `just` recipes build an MPI-enabled extension and launch the suite under `mpiexec`: ```bash -just test-mpi # build with MPI, then run every leg -just test-py-mpi "1;2;4" -m mpi # MPI-marked tests across a rank matrix +just test-mpi # build with MPI, then every MPI leg CI runs +just test-py-mpi # the Python MPI legs only, against the installed build +``` + +`just test-py-mpi` runs the tests that use a world communicator (`-m mpi`) five times: on 1, 2 +and 4 ranks; on 3 ranks with `monoprop_ROUTING=splitmix`, since linear routing refuses a rank +count that is not a power of two; and on 2 ranks with `monoprop_PARTITIONS=2`. Each test times +out after 300 seconds, so a hung collective fails instead of stalling the run. To run one file +by hand: + +```bash +mpiexec -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi ``` The suite's `tests/conftest.py` provides `--with-mpi`; no MPI pytest plugin is needed. Tests marked with `@pytest.mark.mpi` are skipped without that option. -Use `@pytest.mark.mpi(min_size=2)` when a test needs at least two ranks. -Under `--with-mpi`, only rank zero reports pytest output. +Use `@pytest.mark.mpi(min_size=2)` when a test needs at least two ranks. Under +`--with-mpi`, only rank zero reports pytest output; the other ranks print their failures to +stderr, tagged `[rank ]`. Under more than one rank, `tests/conftest.py` gives each rank its own pytest `--basetemp`, `pytest-of-/mpi-rank-` under `$PYTEST_DEBUG_TEMPROOT` when set, else the system temporary @@ -64,7 +75,7 @@ mpiexec --allow-run-as-root -n 2 uv run --no-sync python -m pytest tests --with- `monoprop_ENABLE_MPI` (or the `monoprop.mpi` config-setting) also provisions mpi4py as a build input; see [Building from source](/building#with-mpi) for why. Installing the `mpi` extra alone only provides mpi4py and does not enable C++ MPI support. Tests marked `mpi` are skipped whenever `monoprop.MPI_ABIS` -is empty, and the `comm`/`serial_comm` fixtures fall back to `None` there. Accordingly, the CI matrix has +is empty, and the `comm` fixture then has only its `None` case. Accordingly, the CI matrix has explicit cache-free MPI build lanes on Linux x86-64, Linux ARM64, and macOS; only those lanes run the suite under `mpiexec`. @@ -135,20 +146,22 @@ fabric device per case, whether or not the test sends anything. `monoprop_TEST_E keep the full component set, since they exchange real messages. Turn it off by adding `SKBUILD_CMAKE_DEFINE="monoprop_TEST_EXCLUDE_MPI_FABRIC=OFF"` to the MPI build above. -CTest registers every Boost case individually as a `serial` variant. When the -build has MPI enabled and a launcher is found, it also registers the whole suite -once per rank count in `monoprop_MPI_TEST_PROCS`, labelled `mpi` and `mpi-` — -one entry per rank count rather than per case, because the ranks have to reach -the same collectives. Each MPI entry times out after 600 seconds, preventing a -collective deadlock from occupying the runner indefinitely. +CTest registers every Boost case individually as a `serial` variant. A case that needs +several ranks declares it with a precondition (`cpp/tests/RankPreconditions.h`); in a serial +entry Boost skips it and CTest reports it as skipped. When the build has MPI enabled and a +launcher is found, CTest also registers the whole suite once per rank count: linear routing +on the counts in `monoprop_MPI_TEST_PROCS` (default `2;4`, labels `mpi`, `mpi-`, +`mpi-linear`) and `monoprop_ROUTING=splitmix` on `monoprop_MPI_SPLITMIX_TEST_PROCS` +(default `2;3;4`, labels `mpi`, `mpi-`, `mpi-splitmix`). Each runs one `mpiexec` because +the ranks have to reach the same collectives, allows oversubscription, and times out after +600 seconds. CI runs the non-MPI and MPI labels separately with `--no-tests=error`, so an MPI lane fails if its MPI variant was not registered. Standalone MPI-enabled C++ test executables initialize MPI before constructing propagators and destroy the propagators before finalizing MPI. Select the groups locally with -`just test-cpp` and `just test-cpp-mpi`, which are the same commands CI runs. The -`just test-mpi '1;2;4'` recipe configures and runs the corresponding C++ CTest -rank entries as well as the Python rank matrix. +`just test-cpp` and `just test-cpp-mpi`, which are the same commands CI runs. +`just test-mpi` runs both C++ groups and the Python MPI legs. Run `just code-coverage` to collect coverage in separate serial and MPI-enabled `Coverage` builds and render `monoprop-coverage/index.html`. The MPI collection @@ -226,7 +239,9 @@ The `problem` fixture in `tests/conftest.py` is parametrized over the three general reference problems: LiH, RX/RZ/RY/RZ rotations, and a random circuit. Each invocation loads a fresh problem from `tests/data/` using `load_problem()` in `tests/reference_data.py`. Its explicit case IDs make failures easy to identify. -The `serial_comm` and `comm` fixtures are also provided by `conftest.py`. +`conftest.py` also provides `comm`: `None` (serial) and, under `--with-mpi` with an MPI build, +a duplicate of `COMM_WORLD`. Use it for results every rank agrees on, such as energies and +gradients; test rank-local state such as `evolved_operator` serially, with `comm=None`. ```python notest from monoprop import MajoranaPropagator @@ -249,7 +264,7 @@ request `lih_problem`. For orbital-rotation checks using the S0 molecule, reques `orbital_rotation_problem`. ```python notest -def test_lih_reference(lih_problem, serial_comm): ... +def test_lih_reference(lih_problem): ... ``` Use `@pytest.mark.parametrize` with `pytest.param(..., id="...")` for small diff --git a/tools/vscode-pytest-wrapper.sh b/tools/vscode-pytest-wrapper.sh index ce82fd243..0547a9f5b 100755 --- a/tools/vscode-pytest-wrapper.sh +++ b/tools/vscode-pytest-wrapper.sh @@ -3,8 +3,8 @@ set -euo pipefail # VS Code Python Testing wrapper: # - Keeps discovery stable by running collect-only directly (no mpiexec). -# - Runs tests through mpiexec so both [comm_self] and [comm_world] -# variants exercise the correct code paths. +# - Runs tests through mpiexec so the [comm_world] cases run on a real world +# communicator. # # Environment variables: # monoprop_VSCODE_MPI_MODE all (default) | auto | off From 9f232b9589a4679f8d8e4db86c787261692be787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 13:47:47 +0100 Subject: [PATCH 11/15] =?UTF-8?q?docs(mpi):=20=F0=9F=93=9D=20tighten=20the?= =?UTF-8?q?=20MPI=20test=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- README.md | 2 +- cpp/tests/README.md | 3 ++- cpp/tests/boost-test.cmake | 2 ++ docs/content/docs/testing.mdx | 7 +++++-- justfile | 2 ++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1bc744a94..12314f87d 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ just test-mpi # Python + C++ tests under MPI ``` See the [testing guide](https://docs.monoprop.algorithmiq.tech/testing) -for the with/without-MPI details and the rank matrix. CI has explicit MPI-enabled +for the with/without-MPI details and the MPI configurations. CI has explicit MPI-enabled lanes on Linux x86-64, Linux ARM64, and macOS; installing the `mpi` extra alone does not enable the C++ MPI build. CI requires a registered MPI CTest variant, and each whole-suite MPI run has a 600-second deadlock timeout. Source builds diff --git a/cpp/tests/README.md b/cpp/tests/README.md index bc125b3dd..b9f14c9fc 100644 --- a/cpp/tests/README.md +++ b/cpp/tests/README.md @@ -13,7 +13,8 @@ discovers every Boost.Test case and registers it twice: `mpiexec -n ` for each rank in `monoprop_MPI_TEST_PROCS` (default `2;4`, also labelled `mpi-linear`), plus splitmix-routing entries for each rank in `monoprop_MPI_SPLITMIX_TEST_PROCS` (default `2;3;4`, also labelled - `mpi-splitmix`), registered when an MPI launcher is detected. + `mpi-splitmix`; a CMake cache variable only, set through `SKBUILD_CMAKE_DEFINE`), + registered when an MPI launcher is detected. Cases that need a particular world declare it with a precondition from `RankPreconditions.h`; Boost reports them as skipped elsewhere, and CTest marks diff --git a/cpp/tests/boost-test.cmake b/cpp/tests/boost-test.cmake index a6e53d7ad..7223a8d77 100644 --- a/cpp/tests/boost-test.cmake +++ b/cpp/tests/boost-test.cmake @@ -1,6 +1,8 @@ # 4 as well as 2: at R = 2 the only shifts are 0 and 1, so a non-zero shift always pairs a rank with # its single neighbour and no case distinguishes the shift from the rank index. R = 4 gives shifts # 0..3 and a dense pairwise round of 2 * (R - 1) posts, which R = 2 cannot tell from a single leg. +# 4 is also the only linear rank count that runs the 3-rank refusal +# (mpi_routing_geometry_tests.cpp, test_monoprop_mpi.py): it needs at least 3 ranks. set( monoprop_MPI_TEST_PROCS "2;4" diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index e0dfd4d74..b938e285b 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -47,7 +47,7 @@ out after 300 seconds, so a hung collective fails instead of stalling the run. T by hand: ```bash -mpiexec -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi +mpiexec --map-by :OVERSUBSCRIBE -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi ``` The suite's `tests/conftest.py` provides `--with-mpi`; no MPI pytest plugin is @@ -154,7 +154,10 @@ on the counts in `monoprop_MPI_TEST_PROCS` (default `2;4`, labels `mpi`, `mpi-`, `mpi-splitmix`). Each runs one `mpiexec` because the ranks have to reach the same collectives, allows oversubscription, and times out after -600 seconds. +600 seconds. Only `monoprop_MPI_TEST_PROCS` has a config-setting and an environment +variable; `monoprop_MPI_SPLITMIX_TEST_PROCS` is a CMake cache variable, set through +`SKBUILD_CMAKE_DEFINE="monoprop_MPI_SPLITMIX_TEST_PROCS=3" monoprop_ENABLE_MPI=ON uv sync --all-extras` +(quote a `;`-separated list). CI runs the non-MPI and MPI labels separately with `--no-tests=error`, so an MPI lane fails if its MPI variant was not registered. Standalone MPI-enabled C++ diff --git a/justfile b/justfile index 93ed1e592..88dc5c7f6 100644 --- a/justfile +++ b/justfile @@ -119,6 +119,8 @@ test-py-mpi LABEL='': } leg ranks1 1 leg ranks2 2 + # 4 is also the only linear count that runs the 3-rank refusal + # (mpi_routing_geometry_tests.cpp, test_monoprop_mpi.py). leg ranks4 4 leg ranks3-splitmix 3 monoprop_ROUTING=splitmix leg ranks2-partitions2 2 monoprop_PARTITIONS=2 From bd773726c2a39234570487e9d7afa8aa5870e6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 15:35:47 +0100 Subject: [PATCH 12/15] =?UTF-8?q?docs(mpi):=20=F0=9F=93=9D=20correct=20the?= =?UTF-8?q?=20splitmix=20rank-list=20advice=20and=20the=20three-rank=20ref?= =?UTF-8?q?usal=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- cpp/tests/boost-test.cmake | 4 ++-- docs/content/docs/testing.mdx | 7 ++++--- justfile | 4 ++-- tests/test_monoprop_mpi.py | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cpp/tests/boost-test.cmake b/cpp/tests/boost-test.cmake index 7223a8d77..1b8151d5d 100644 --- a/cpp/tests/boost-test.cmake +++ b/cpp/tests/boost-test.cmake @@ -1,8 +1,8 @@ # 4 as well as 2: at R = 2 the only shifts are 0 and 1, so a non-zero shift always pairs a rank with # its single neighbour and no case distinguishes the shift from the rank index. R = 4 gives shifts # 0..3 and a dense pairwise round of 2 * (R - 1) posts, which R = 2 cannot tell from a single leg. -# 4 is also the only linear rank count that runs the 3-rank refusal -# (mpi_routing_geometry_tests.cpp, test_monoprop_mpi.py): it needs at least 3 ranks. +# Of these defaults only 4 runs mpi_routing_geometry_tests.cpp, which needs at least 3 ranks +# under linear routing. set( monoprop_MPI_TEST_PROCS "2;4" diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index b938e285b..789dcfaab 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -155,9 +155,10 @@ on the counts in `monoprop_MPI_TEST_PROCS` (default `2;4`, labels `mpi`, `mpi-`, `mpi-splitmix`). Each runs one `mpiexec` because the ranks have to reach the same collectives, allows oversubscription, and times out after 600 seconds. Only `monoprop_MPI_TEST_PROCS` has a config-setting and an environment -variable; `monoprop_MPI_SPLITMIX_TEST_PROCS` is a CMake cache variable, set through -`SKBUILD_CMAKE_DEFINE="monoprop_MPI_SPLITMIX_TEST_PROCS=3" monoprop_ENABLE_MPI=ON uv sync --all-extras` -(quote a `;`-separated list). +variable; `monoprop_MPI_SPLITMIX_TEST_PROCS` is a CMake cache variable. Set a single rank count +with `SKBUILD_CMAKE_DEFINE="monoprop_MPI_SPLITMIX_TEST_PROCS=3" monoprop_ENABLE_MPI=ON uv sync --all-extras`. +`SKBUILD_CMAKE_DEFINE` splits on `;`, so set a list on the configured tree instead: +`cmake -D monoprop_MPI_SPLITMIX_TEST_PROCS="2;3" build/editable/Release-mpi`. CI runs the non-MPI and MPI labels separately with `--no-tests=error`, so an MPI lane fails if its MPI variant was not registered. Standalone MPI-enabled C++ diff --git a/justfile b/justfile index 88dc5c7f6..d258bc4fe 100644 --- a/justfile +++ b/justfile @@ -119,8 +119,8 @@ test-py-mpi LABEL='': } leg ranks1 1 leg ranks2 2 - # 4 is also the only linear count that runs the 3-rank refusal - # (mpi_routing_geometry_tests.cpp, test_monoprop_mpi.py). + # Of these legs only ranks4 runs the three-rank refusal in test_monoprop_mpi.py, which needs + # at least 3 ranks under linear routing. leg ranks4 4 leg ranks3-splitmix 3 monoprop_ROUTING=splitmix leg ranks2-partitions2 2 monoprop_PARTITIONS=2 diff --git a/tests/test_monoprop_mpi.py b/tests/test_monoprop_mpi.py index 6a9493124..8ccf85bd4 100644 --- a/tests/test_monoprop_mpi.py +++ b/tests/test_monoprop_mpi.py @@ -17,7 +17,7 @@ Every test carries the ``mpi`` marker and runs on ``comm_world``, the session's duplicate of COMM_WORLD. ``just test-py-mpi`` runs them in each configuration CI covers; by hand: - mpiexec -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi + mpiexec --map-by :OVERSUBSCRIBE -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi """ from __future__ import annotations From 1579cc3cc460f24ea00fcc0993c0945444fabe74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 28 Oct 2026 19:33:11 +0100 Subject: [PATCH 13/15] Apply suggestion from @robertodr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Roberto Di Remigio Eikås --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8db29c582..d57dea35c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,6 @@ jobs: mpi: "on" runs-on: ${{ matrix.runner}} - # A lane takes about 15 minutes; the per-test timeouts catch a hang first, this only bounds it. timeout-minutes: 60 env: From a733f9b52466fd964d72673556b0d9c578c90d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 30 Oct 2026 10:04:34 +0100 Subject: [PATCH 14/15] =?UTF-8?q?test(ci):=20=F0=9F=90=9B=20pin=20the=20ro?= =?UTF-8?q?uting=20of=20linear=20MPI=20variants=20and=20report=20results?= =?UTF-8?q?=20of=20failed=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mpi-linear CTest variants now set monoprop_ROUTING=linear explicitly, so an inherited monoprop_ROUTING=splitmix can no longer run splitmix under the linear label. The Codecov test-results uploads run unless the job is cancelled, so a failing test step still reports the results it wrote. Both found by CodeRabbit. Assisted-by: Pi:claude-opus-5-5 --- .github/workflows/test.yml | 4 +++- cpp/tests/boostAddTests.cmake | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d57dea35c..875b48381 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -117,7 +117,9 @@ jobs: if: matrix.mpi == 'on' run: just test-py-mpi "${{ steps.test-group-label.outputs.label }}" + # !cancelled(): a failing test step must still report the results it wrote. - name: Upload serial test results to Codecov + if: ${{ !cancelled() }} uses: codecov/codecov-action@v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -127,7 +129,7 @@ jobs: fail_ci_if_error: true - name: Upload MPI test results to Codecov - if: matrix.mpi == 'on' + if: ${{ !cancelled() && matrix.mpi == 'on' }} uses: codecov/codecov-action@v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/cpp/tests/boostAddTests.cmake b/cpp/tests/boostAddTests.cmake index c60ab3749..8014ff29a 100644 --- a/cpp/tests/boostAddTests.cmake +++ b/cpp/tests/boostAddTests.cmake @@ -269,10 +269,15 @@ function(register_mpi_variant RANKS ROUTING) endif() set(_name "${TEST_TARGET}_mpi_${RANKS}") - set(_env ${_mpi_env}) + # Set for linear too: an inherited monoprop_ROUTING=splitmix would otherwise run splitmix under + # the mpi-linear label and leave linear routing untested. + set( + _env + ${_mpi_env} + "monoprop_ROUTING=${ROUTING}" + ) if(ROUTING STREQUAL "splitmix") string(APPEND _name "_splitmix") - list(APPEND _env "monoprop_ROUTING=splitmix") endif() register_variant("${_name}" COMMAND From 335faa32a04c3117e312a5ce7a90e8cb113f617d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 30 Oct 2026 10:48:10 +0100 Subject: [PATCH 15/15] =?UTF-8?q?test(mpi):=20=F0=9F=90=9B=20run=20each=20?= =?UTF-8?q?Python=20MPI=20leg=20in=20exactly=20its=20own=20configuration?= =?UTF-8?q?=20and=20fix=20the=20test=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python MPI legs drop monoprop_ROUTING and monoprop_PARTITIONS inherited from the shell before applying their own, and ranks4 pins linear routing, so an inherited splitmix setting can no longer skip the three-rank refusal. The testing guide now rebuilds after changing the splitmix rank list (variants are registered when the test binary is built) and shows how to check them, runs the serial C++ group of an MPI build with monoprop_ENABLE_MPI=ON, and links evolved_operator. Found by CodeRabbit. Assisted-by: Pi:claude-opus-5-5 --- docs/content/docs/testing.mdx | 17 ++++++++++++++--- justfile | 7 ++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index 789dcfaab..04233d685 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -158,13 +158,22 @@ the ranks have to reach the same collectives, allows oversubscription, and times variable; `monoprop_MPI_SPLITMIX_TEST_PROCS` is a CMake cache variable. Set a single rank count with `SKBUILD_CMAKE_DEFINE="monoprop_MPI_SPLITMIX_TEST_PROCS=3" monoprop_ENABLE_MPI=ON uv sync --all-extras`. `SKBUILD_CMAKE_DEFINE` splits on `;`, so set a list on the configured tree instead: -`cmake -D monoprop_MPI_SPLITMIX_TEST_PROCS="2;3" build/editable/Release-mpi`. +`cmake -D monoprop_MPI_SPLITMIX_TEST_PROCS="2;3" build/editable/Release-mpi`. Rebuild afterwards, +since CTest registers the variants when the test binary is built, not at configure time, and check +the result: + +```bash +cmake --build build/editable/Release-mpi +ctest --test-dir build/editable/Release-mpi -N -L mpi-splitmix +``` CI runs the non-MPI and MPI labels separately with `--no-tests=error`, so an MPI lane fails if its MPI variant was not registered. Standalone MPI-enabled C++ test executables initialize MPI before constructing propagators and destroy the propagators before finalizing MPI. Select the groups locally with -`just test-cpp` and `just test-cpp-mpi`, which are the same commands CI runs. +`just test-cpp` and `just test-cpp-mpi`, which are the same commands CI runs. `just test-cpp` picks +the build tree from `monoprop_ENABLE_MPI`, so run the serial group of an MPI build with +`monoprop_ENABLE_MPI=ON just test-cpp`, as CI's MPI jobs do. `just test-mpi` runs both C++ groups and the Python MPI legs. Run `just code-coverage` to collect coverage in separate serial and MPI-enabled @@ -245,7 +254,9 @@ Each invocation loads a fresh problem from `tests/data/` using `load_problem()` in `tests/reference_data.py`. Its explicit case IDs make failures easy to identify. `conftest.py` also provides `comm`: `None` (serial) and, under `--with-mpi` with an MPI build, a duplicate of `COMM_WORLD`. Use it for results every rank agrees on, such as energies and -gradients; test rank-local state such as `evolved_operator` serially, with `comm=None`. +gradients; test rank-local state such as +[evolved_operator][monoprop.monomial_propagator.MonomialPropagator.evolved_operator] serially, +with `comm=None`. ```python notest from monoprop import MajoranaPropagator diff --git a/justfile b/justfile index d258bc4fe..ef5023a3e 100644 --- a/justfile +++ b/justfile @@ -106,8 +106,9 @@ test-py-mpi LABEL='': report="pytest-mpi-$label-$name.xml" fi echo "Python MPI leg $name: $ranks rank(s)${*:+, $*}" - # OMPI_COMM_WORLD_RANK is Open MPI's rank variable, PMI_RANK the MPICH family's. - env "$@" {{ mpiexec }} -n "$ranks" bash -c ' + # OMPI_COMM_WORLD_RANK is Open MPI's rank variable, PMI_RANK the MPICH family's. A leg runs + # exactly the configuration it names: settings inherited from the shell are dropped first. + env -u monoprop_ROUTING -u monoprop_PARTITIONS "$@" {{ mpiexec }} -n "$ranks" bash -c ' report="$1" shift rank="${OMPI_COMM_WORLD_RANK:-${PMI_RANK:-0}}" @@ -121,7 +122,7 @@ test-py-mpi LABEL='': leg ranks2 2 # Of these legs only ranks4 runs the three-rank refusal in test_monoprop_mpi.py, which needs # at least 3 ranks under linear routing. - leg ranks4 4 + leg ranks4 4 monoprop_ROUTING=linear leg ranks3-splitmix 3 monoprop_ROUTING=splitmix leg ranks2-partitions2 2 monoprop_PARTITIONS=2