@@ -896,3 +896,172 @@ TEST(BatchedEvolveTester, checkIntermediateResultSaveNoneWithObservables) {
896896 1 ); // One observable (pauliZ)
897897 }
898898}
899+
900+ // Test to reproduce coefficient mismatch bug when batched operators are sorted
901+ // by degrees but coefficients are taken from unsorted ops.
902+ // Bug: CuDensityMatOpConverter.cpp:528-529 uses ops[termIdx] instead of
903+ // batchedProductTerms[i][termIdx]
904+ TEST (BatchedEvolveTester, checkCoefficientMismatchAfterSorting) {
905+ // Use 2 qubits (degrees 0 and 1) to ensure sorting changes term order
906+ const cudaq::dimension_map dims = {{0 , 2 }, {1 , 2 }};
907+
908+ // We'll construct two Hamiltonians where terms are ordered such that sorting
909+ // by degrees will reorder them. Then we use X operators (non-diagonal) so
910+ // that different coefficients lead to measurably different evolution.
911+ //
912+ // Hamiltonian structure (before sorting):
913+ // term[0]: coeff1 * X(1) -> degrees = {1}
914+ // term[1]: coeff0 * X(0) -> degrees = {0}
915+ //
916+ // After stable_sort by degrees:
917+ // term[0]: coeff0 * X(0) -> degrees = {0}
918+ // term[1]: coeff1 * X(1) -> degrees = {1}
919+ //
920+ // Bug: coeffs[i] = ops[i][termIdx].get_coefficient() uses unsorted index
921+ // but prodTerms[i] = batchedProductTerms[i][termIdx] uses sorted index
922+ // This causes coefficient mismatch!
923+
924+ // Batch 1: H1 = f1 * X(1) + f0 * X(0) where f1=0.2, f0=0.1
925+ // After sorting: H1 = f0 * X(0) + f1 * X(1)
926+ // Batch 2: H2 = g1 * X(1) + g0 * X(0) where g1=0.4, g0=0.3
927+ // After sorting: H2 = g0 * X(0) + g1 * X(1)
928+ //
929+ // With the bug, when processing sorted term[0] (X(0)):
930+ // - prodTerms uses X(0) (correct)
931+ // - coeffs uses ops[0] (unsorted) which gives f1=0.2 for batch1, g1=0.4 for
932+ // batch2
933+ // instead of f0=0.1, g0=0.3
934+
935+ const double f0 = 0.1 , f1 = 0.2 ;
936+ const double g0 = 0.3 , g1 = 0.4 ;
937+
938+ // Use time-dependent callbacks to trigger the non-constant coefficient path
939+ auto make_td_coeff = [](double val) {
940+ return cudaq::scalar_operator (
941+ [val](const std::unordered_map<std::string, std::complex <double >>
942+ ¶meters) { return std::complex <double >(val, 0.0 ); });
943+ };
944+
945+ // Hamiltonian 1: terms added in order degree1, degree0
946+ cudaq::sum_op<cudaq::matrix_handler> ham1 =
947+ make_td_coeff (2.0 * M_PI * f1) * cudaq::spin_op::x (1 ) +
948+ make_td_coeff (2.0 * M_PI * f0) * cudaq::spin_op::x (0 );
949+
950+ // Hamiltonian 2: terms added in order degree1, degree0
951+ cudaq::sum_op<cudaq::matrix_handler> ham2 =
952+ make_td_coeff (2.0 * M_PI * g1) * cudaq::spin_op::x (1 ) +
953+ make_td_coeff (2.0 * M_PI * g0) * cudaq::spin_op::x (0 );
954+
955+ constexpr int numSteps = 50 ;
956+ std::vector<double > steps = cudaq::linspace (0.0 , 1.0 , numSteps);
957+ cudaq::schedule schedule (steps, {" t" });
958+
959+ // Initial state: |00> = (1, 0, 0, 0) in computational basis
960+ // For 2 qubits: |00>, |01>, |10>, |11>
961+ auto initialState = cudaq::state::from_data (
962+ std::vector<std::complex <double >>{1.0 , 0.0 , 0.0 , 0.0 });
963+
964+ // Observables: Z(0) and Z(1) measured separately
965+ cudaq::sum_op<cudaq::matrix_handler> obsZ0 (cudaq::spin_op::z (0 ));
966+ cudaq::sum_op<cudaq::matrix_handler> obsZ1 (cudaq::spin_op::z (1 ));
967+
968+ cudaq::integrators::runge_kutta integrator (4 , 0.01 );
969+ auto results = cudaq::__internal__::evolveBatched (
970+ {ham1, ham2}, dims, schedule, {initialState, initialState}, integrator,
971+ {}, {obsZ0, obsZ1}, cudaq::IntermediateResultSave::ExpectationValue);
972+
973+ EXPECT_EQ (results.size (), 2 );
974+
975+ // For independent X rotations on each qubit:
976+ // H = omega0 * X(0) + omega1 * X(1)
977+ // <Z(0)>(t) = cos(2 * omega0 * t)
978+ // <Z(1)>(t) = cos(2 * omega1 * t)
979+ //
980+ // Batch 1: omega0 = 2*pi*f0, omega1 = 2*pi*f1
981+ // <Z(0)>(t) = cos(4*pi*f0*t) = cos(4*pi*0.1*t)
982+ // <Z(1)>(t) = cos(4*pi*f1*t) = cos(4*pi*0.2*t)
983+ //
984+ // Batch 2: omega0 = 2*pi*g0, omega1 = 2*pi*g1
985+ // <Z(0)>(t) = cos(4*pi*g0*t) = cos(4*pi*0.3*t)
986+ // <Z(1)>(t) = cos(4*pi*g1*t) = cos(4*pi*0.4*t)
987+ //
988+ // With the bug (coefficients swapped):
989+ // Batch 1: omega0 = 2*pi*f1, omega1 = 2*pi*f0 (swapped!)
990+ // <Z(0)>(t) = cos(4*pi*0.2*t) <- wrong!
991+ // <Z(1)>(t) = cos(4*pi*0.1*t) <- wrong!
992+
993+ // Check batch 1 results
994+ {
995+ EXPECT_TRUE (results[0 ].expectation_values .has_value ());
996+ const auto &expValsList = results[0 ].expectation_values .value ();
997+ EXPECT_EQ (expValsList.size (), numSteps);
998+
999+ int count = 0 ;
1000+ for (auto expVals : expValsList) {
1001+ EXPECT_EQ (expVals.size (), 2 ); // Two observables
1002+ double t = steps[count];
1003+
1004+ // Expected values with CORRECT coefficients
1005+ double expectedZ0 = std::cos (4.0 * M_PI * f0 * t);
1006+ double expectedZ1 = std::cos (4.0 * M_PI * f1 * t);
1007+
1008+ // What we'd get with WRONG (swapped) coefficients
1009+ double wrongZ0 = std::cos (4.0 * M_PI * f1 * t);
1010+ double wrongZ1 = std::cos (4.0 * M_PI * f0 * t);
1011+
1012+ double actualZ0 = (double )expVals[0 ];
1013+ double actualZ1 = (double )expVals[1 ];
1014+
1015+ // If bug exists, actualZ0 would be close to wrongZ0 instead of expectedZ0
1016+ bool matchesCorrect = (std::abs (actualZ0 - expectedZ0) < 0.05 ) &&
1017+ (std::abs (actualZ1 - expectedZ1) < 0.05 );
1018+ bool matchesWrong = (std::abs (actualZ0 - wrongZ0) < 0.05 ) &&
1019+ (std::abs (actualZ1 - wrongZ1) < 0.05 );
1020+
1021+ if (t > 0.1 ) { // Skip early times where values might be similar
1022+ if (matchesWrong && !matchesCorrect) {
1023+ std::cout << " BUG DETECTED at t=" << t << " : Batch 1\n " ;
1024+ std::cout << " <Z(0)> actual=" << actualZ0
1025+ << " , expected=" << expectedZ0 << " , wrong=" << wrongZ0
1026+ << " \n " ;
1027+ std::cout << " <Z(1)> actual=" << actualZ1
1028+ << " , expected=" << expectedZ1 << " , wrong=" << wrongZ1
1029+ << " \n " ;
1030+ }
1031+ }
1032+
1033+ EXPECT_NEAR (actualZ0, expectedZ0, 0.05 )
1034+ << " Batch 1, t=" << t << " : <Z(0)> mismatch - coefficient bug?" ;
1035+ EXPECT_NEAR (actualZ1, expectedZ1, 0.05 )
1036+ << " Batch 1, t=" << t << " : <Z(1)> mismatch - coefficient bug?" ;
1037+
1038+ count++;
1039+ }
1040+ }
1041+
1042+ // Check batch 2 results
1043+ {
1044+ EXPECT_TRUE (results[1 ].expectation_values .has_value ());
1045+ const auto &expValsList = results[1 ].expectation_values .value ();
1046+ EXPECT_EQ (expValsList.size (), numSteps);
1047+
1048+ int count = 0 ;
1049+ for (auto expVals : expValsList) {
1050+ EXPECT_EQ (expVals.size (), 2 );
1051+ double t = steps[count];
1052+
1053+ double expectedZ0 = std::cos (4.0 * M_PI * g0 * t);
1054+ double expectedZ1 = std::cos (4.0 * M_PI * g1 * t);
1055+
1056+ double actualZ0 = (double )expVals[0 ];
1057+ double actualZ1 = (double )expVals[1 ];
1058+
1059+ EXPECT_NEAR (actualZ0, expectedZ0, 0.05 )
1060+ << " Batch 2, t=" << t << " : <Z(0)> mismatch - coefficient bug?" ;
1061+ EXPECT_NEAR (actualZ1, expectedZ1, 0.05 )
1062+ << " Batch 2, t=" << t << " : <Z(1)> mismatch - coefficient bug?" ;
1063+
1064+ count++;
1065+ }
1066+ }
1067+ }
0 commit comments