// Copyright 2021 DeepMind Technologies Limited // // 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. // Tests for engine/engine_util_solve.c. #include "src/engine/engine_util_solve.h" #include #include #include #include #include #include #include #include #include #include #include "src/engine/engine_util_blas.h" #include "src/engine/engine_util_misc.h" #include "src/engine/engine_util_sparse.h" #include "test/fixture.h" namespace mujoco { namespace { using ::testing::Pointwise; using ::std::setw; using ::std::string; using ::std::vector; using ::testing::ElementsAre; using QCQP2Test = MujocoTest; TEST_F(QCQP2Test, DegenerateAMatrix) { // A 2x2 matrix with determinant zero. const mjtNum Ain[9]{6, -15, 2, -5}; // Any values will do for these three inputs. const mjtNum bin[3]{-12, 49}; const mjtNum d[3]{11, 31}; const mjtNum r = 0.01; // Make output array explicitly nonzero to simulate uninitialized memory. mjtNum res[2]{999, 999}; EXPECT_EQ(mju_QCQP2(res, Ain, bin, d, r), 0); EXPECT_EQ(res[0], 0); EXPECT_EQ(res[1], 0); } using QCQP3Test = MujocoTest; TEST_F(QCQP3Test, DegenerateAMatrix) { // A 3x3 matrix with determinant zero. const mjtNum Ain[9]{1, 4, -2, -3, -7, 5, 2, -9, 0}; // Any values will do for these three inputs. const mjtNum bin[3]{-12, 49, 8}; const mjtNum d[3]{11, 31, -23}; const mjtNum r = 0.1; // Make output array explicitly nonzero to simulate uninitialized memory. mjtNum res[3]{999, 999, 999}; EXPECT_EQ(mju_QCQP3(res, Ain, bin, d, r), 0); EXPECT_EQ(res[0], 0); EXPECT_EQ(res[1], 0); EXPECT_EQ(res[2], 0); } // --------------------------- mju_boxQP --------------------------------------- using BoxQPTest = MujocoTest; // utility: compute QP objective = 0.5*x'*H*x + x'*g mjtNum objective(const mjtNum* x, const mjtNum* H, const mjtNum* g, int n) { return 0.5 * mju_mulVecMatVec(x, H, x, n) + mju_dot(x, g, n); } // utility: test if res is the minimum of a given box-QP problem bool isQPminimum(const mjtNum* res, const mjtNum* H, const mjtNum* g, int n, const mjtNum* lower, const mjtNum* upper) { const mjtNum eps = MjTol(1e-4, 5e-2); // epsilon used for nudging const mjtNum threshold = MjTol(0, -2e-3); // comparison threshold bool is_minimum = true; mjtNum* res_nudge = (mjtNum*)mju_malloc(sizeof(mjtNum) * n); // get solution value mjtNum value = objective(res, H, g, n); mjtNum value_nudge; // compare to nudged solution mju_copy(res_nudge, res, n); for (int i = 0; i < n; i++) { // nudge down res_nudge[i] = res[i] - eps; if (lower) { res_nudge[i] = mju_max(lower[i], res_nudge[i]); } value_nudge = objective(res_nudge, H, g, n); if (value_nudge - value < threshold) { is_minimum = false; break; } // nudge up res_nudge[i] = res[i] + eps; if (upper) { res_nudge[i] = mju_min(upper[i], res_nudge[i]); } value_nudge = objective(res_nudge, H, g, n); if (value_nudge - value < threshold) { is_minimum = false; break; } // reset res_nudge[i] = res[i]; } mju_free(res_nudge); return is_minimum; } // utility: define QP with pseudorandom values void randomBoxQP(int n, mjtNum* H, mjtNum* g, mjtNum* lower, mjtNum* upper, int seed) { // make distribution using seed std::mt19937_64 rng; rng.seed(seed); std::normal_distribution dist(0, 1); // square root of H mjtNum* sqrtH = (mjtNum*)mju_malloc(sizeof(mjtNum) * n * n); for (int i = 0; i < n; i++) { g[i] = dist(rng); lower[i] = 5 * dist(rng); upper[i] = 5 * dist(rng); // fix invalid bounds if (lower[i] > upper[i]) { mjtNum tmp = upper[i]; upper[i] = lower[i]; lower[i] = tmp; } // sample temp for (int j = 0; j < n; j++) { sqrtH[n * i + j] = dist(rng); } } // make SPD matrix H mju_mulMatTMat(H, sqrtH, sqrtH, n, n, n); mju_free(sqrtH); } // test mju_boxQP on a small unbounded QP TEST_F(BoxQPTest, UnboundedQP) { // small arrays, allocate on stack static const int n = 2; mjtNum H[n * n] = {2, 0, 0, 2}; mjtNum g[n] = {1, 3}; mjtNum res[n] = {0, 0}; mjtNum R[n * (n + 7)]; int nfree = mju_boxQP(res, R, /*index=*/nullptr, H, g, n, /*lower=*/nullptr, /*upper=*/nullptr); // no bounds, expect Newton point EXPECT_EQ(nfree, 2); EXPECT_MJTNUM_EQ(res[0], -g[0] / H[0]); EXPECT_MJTNUM_EQ(res[1], -g[1] / H[3]); // check that solution is actual minimum EXPECT_TRUE(isQPminimum(res, H, g, n, /*lower=*/nullptr, /*upper=*/nullptr)); // perturb solution, expected it no longer be the minimum res[0] += MjTol(0.001, 0.1); EXPECT_FALSE(isQPminimum(res, H, g, n, /*lower=*/nullptr, /*upper=*/nullptr)); // negative-definite Hessian, no solution H[0] = -1; nfree = mju_boxQP(res, R, /*index=*/nullptr, H, g, n, /*lower=*/nullptr, /*upper=*/nullptr); EXPECT_EQ(nfree, -1); } // small bounded QP with asymmetric Hessian (upper triangle ignored) TEST_F(BoxQPTest, AsymmetricUpperIgnored) { // small arrays, allocate on stack static const int n = 2; mjtNum H[n * n] = {1, -400, 0, 1}; mjtNum g[n] = {1, 3}; mjtNum res[n] = {0, 0}; mjtNum lower[n] = {-2, -2}; mjtNum upper[n] = {0, 0}; int index[n]; mjtNum R[n * (n + 7)]; // solve box-QP int nfree = mju_boxQP(res, R, index, H, g, n, lower, upper); EXPECT_EQ(nfree, 1); EXPECT_MJTNUM_EQ(res[0], -g[0] / H[0]); EXPECT_MJTNUM_EQ(res[1], lower[1]); } // verify mju_boxQP reads only the lower triangle of H by poisoning the upper // triangle with NaN and comparing to a clean symmetric solve TEST_F(BoxQPTest, UpperTrianglePoisoned) { int n = 30; const mjtNum nan = std::numeric_limits::quiet_NaN(); // allocate on heap mjtNum *H, *g, *lower, *upper; // inputs mjtNum *res, *R; // outputs int* index; // outputs mju_boxQPmalloc(&res, &R, &index, &H, &g, n, &lower, &upper); struct Cleanup { mjtNum *res, *R, *H, *g, *lower, *upper; int* index; ~Cleanup() { mju_free(res); mju_free(R); mju_free(index); mju_free(H); mju_free(g); mju_free(lower); mju_free(upper); } } cleanup{res, R, H, g, lower, upper, index}; // generate a symmetric SPD Hessian and bounded QP problem randomBoxQP(n, H, g, lower, upper, /*seed=*/1); int maxiter = 100; mjtNum mingrad = MjTol(1E-16, 1E-5); mjtNum backtrack = 0.5; mjtNum minstep = MjTol(1E-22, 1E-10); mjtNum armijo = 0.1; // solve with symmetric H to get the reference result mju_zero(res, n); int nfree_ref = mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter, mingrad, backtrack, minstep, armijo, nullptr, 0); ASSERT_GT(nfree_ref, -1); // save reference std::vector res_ref(res, res + n); std::vector index_ref(index, index + n); std::vector R_ref(R, R + nfree_ref * nfree_ref); // poison the strict upper triangle of H with NaN for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { H[n * i + j] = nan; } } // solve again; result must match because only lower triangle should be read mju_zero(res, n); int nfree_poisoned = mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter, mingrad, backtrack, minstep, armijo, nullptr, 0); EXPECT_EQ(nfree_poisoned, nfree_ref); for (int i = 0; i < n; i++) { EXPECT_EQ(res[i], res_ref[i]) << "mismatch at index " << i; } for (int i = 0; i < nfree_ref; i++) { EXPECT_EQ(index[i], index_ref[i]) << "index mismatch at " << i; for (int j = 0; j <= i; j++) { int k = i * nfree_ref + j; EXPECT_EQ(R[k], R_ref[k]) << "R mismatch at row " << i << ", col " << j; } } } // test mju_boxQP on a single random bounded QP TEST_F(BoxQPTest, BoundedQP) { int n = 50; // problem size // allocate on heap mjtNum *H, *g, *lower, *upper; // inputs mjtNum *res, *R; // outputs int* index; // outputs mju_boxQPmalloc(&res, &R, &index, &H, &g, n, &lower, &upper); randomBoxQP(n, H, g, lower, upper, /*seed=*/1); // initialize res mju_zero(res, n); // use default options int maxiter = 100; // maximum number of iterations mjtNum mingrad = MjTol(1E-16, 1E-5); // minimum squared norm of (unclamped) gradient mjtNum backtrack = 0.5; // backtrack factor for decreasing stepsize mjtNum minstep = MjTol(1E-22, 1E-10); // minimum stepsize for linesearch mjtNum armijo = 0.1; // Armijo parameter // logging static const int logsz = 10000; char log[logsz]; int nfree = mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter, mingrad, backtrack, minstep, armijo, log, logsz); // ADD_FAILURE() << log; // uncomment to print `log` to error log // check solution EXPECT_GT(nfree, -1); EXPECT_TRUE(isQPminimum(res, H, g, n, lower, upper)); // verify clamping int j = nfree > 0 ? 0 : -1; for (int i = 0; i < n; i++) { if (j >= 0 && i == index[j]) { // free dimension EXPECT_GT(res[i], lower[i]); EXPECT_LT(res[i], upper[i]); j++; } else { // clamped dimension EXPECT_TRUE(res[i] == lower[i] || res[i] == upper[i]); } } mju_free(res); mju_free(R); mju_free(index); mju_free(H); mju_free(g); mju_free(lower); mju_free(upper); } // test mju_boxQP on a set of random bounded QPs TEST_F(BoxQPTest, BoundedQPvariations) { if constexpr (sizeof(mjtNum) == sizeof(float)) { GTEST_SKIP() << "BoxQP test permutations contain ill-conditioned matrices which " "natively fail to find descent directions under float32 precision"; } int nmax = 100; // allocate maximum size on heap mjtNum *H, *g, *lower, *upper; // inputs mjtNum *res, *R; // outputs int* index; // outputs mju_boxQPmalloc(&res, &R, &index, &H, &g, nmax, &lower, &upper); // logging static const int logsz = 10000; char log[logsz]; int seed = 1; for (int n : {3, 30, 100}) { int count = 0; int factorizations = 0; for (mjtNum scaleH : {.01, 1.0, 100.0}) { for (mjtNum scaleg : {.01, 1.0, 100.0}) { for (mjtNum scalebounds : {.01, 1.0, 100.0}) { // make random box-QP randomBoxQP(n, H, g, lower, upper, seed++); mju_scl(H, H, scaleH, n * n); mju_scl(g, g, scaleg, n); mju_scl(lower, lower, scalebounds, n); mju_scl(upper, upper, scalebounds, n); // initialize with zeros mju_zero(res, n); // default algorithm options int maxiter = 100; mjtNum mingrad = MjTol(1E-16, 1E-5); mjtNum backtrack = 0.5; mjtNum minstep = MjTol(1E-22, 1E-10); mjtNum armijo = 0.1; // solve box-QP with logging int nfree = mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter, mingrad, backtrack, minstep, armijo, log, logsz); // check solution EXPECT_GT(nfree, -1) << log; EXPECT_TRUE(isQPminimum(res, H, g, n, lower, upper)) << "n " << n << '\n' << "scaleH " << scaleH << '\n' << "scaleg " << scaleg << '\n' << "scalebounds " << scalebounds << '\n'; // wrap log with string, count factorizations string slog(log); string factorstr = "factorizations="; std::size_t index = slog.find(factorstr) + factorstr.length(); int num_factor = std::stoi(slog.substr(index, 3)); // never more than 6 factorizations EXPECT_LE(num_factor, 6); factorizations += num_factor; count++; } } } double meanfactor = ((double)factorizations) / count; // average of 4.5 factorizations is expected EXPECT_LE(meanfactor, 5.0); std::cerr << "n=" << setw(3) << n << ": average of " << meanfactor << " factorizations\n"; } mju_free(res); mju_free(R); mju_free(index); mju_free(H); mju_free(g); mju_free(lower); mju_free(upper); } // ------------------------- band matrices ------------------------------------- using BandMatrixTest = MujocoTest; // utility: random "arrowhead", banded-then-dense SPD matrix // optional random vector and diagonal regularizer void randomBanded(mjtNum* H, int nTotal, int nBand, int nDense, int seed, mjtNum* vec = nullptr, mjtNum reg = 0) { // make distribution using seed std::mt19937_64 rng; rng.seed(seed); std::normal_distribution dist(0, 1); // allocate square root mjtNum* sqrtH = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal * nTotal); // sample for (int i = 0; i < nTotal; i++) { if (vec) vec[i] = dist(rng); for (int j = 0; j < nTotal; j++) { sqrtH[nTotal * i + j] = dist(rng); } } // make SPD matrix H mju_mulMatTMat(H, sqrtH, sqrtH, nTotal, nTotal, nTotal); // set zeros int nSparse = nTotal - nDense; for (int i = 0; i < nSparse; i++) { int nzeros = mjMAX(0, i + 1 - nBand); for (int j = 0; j < nzeros; j++) { H[nTotal * i + j] = 0; H[nTotal * j + i] = 0; } } // add regularizer to diagonal for (int i = 0; i < nTotal; i++) { H[nTotal * i + i] += reg; } mju_free(sqrtH); } // test banded-vector diagonal values TEST_F(BandMatrixTest, Diagonal) { int seed = 1; int nTotal = 8; for (int nBand : {1, 3}) { for (int nDense : {0, 2}) { // allocate int nB = (nTotal - nDense) * nBand + nDense * nTotal; mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB); int nH = nTotal * nTotal; mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); // make random banded SPD matrix, dense representation randomBanded(H, nTotal, nBand, nDense, seed++); // convert to banded representation mju_dense2Band(B, H, nTotal, nBand, nDense); // expect diagonals to be equal for (int i = 0; i < nTotal; i++) { EXPECT_EQ(H[i * nTotal + i], B[mju_bandDiag(i, nTotal, nBand, nDense)]); } mju_free(H); mju_free(B); } } } // test conversion of banded <-> dense TEST_F(BandMatrixTest, Conversion) { int seed = 1; int nTotal = 8; for (int nBand : {0, 1, 3}) { for (int nDense : {0, 2}) { // allocate int nB = (nTotal - nDense) * nBand + nDense * nTotal; mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB); int nH = nTotal * nTotal; mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); mjtNum* H1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); // make random banded SPD matrix, dense randomBanded(H, nTotal, nBand, nDense, seed++); // convert to banded mju_dense2Band(B, H, nTotal, nBand, nDense); // convert back to dense mju_band2Dense(H1, B, nTotal, nBand, nDense, /*flg_sym=*/1); // expect exact equality EXPECT_EQ(AsVector(H, nH), AsVector(H1, nH)); mju_free(H1); mju_free(H); mju_free(B); } } } // test banded-vector multiplication TEST_F(BandMatrixTest, Multiplication) { int seed = 1; int nTotal = 8; for (int nBand : {0, 1, 3}) { for (int nDense : {0, 2}) { // allocate int nB = (nTotal - nDense) * nBand + nDense * nTotal; mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB); int nH = nTotal * nTotal; mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); mjtNum* vec = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); mjtNum* res = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); mjtNum* res1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); // make random banded SPD matrix, dense randomBanded(H, nTotal, nBand, nDense, seed++, vec); // multiply dense mju_mulMatVec(res, H, vec, nTotal, nTotal); // convert to banded, multiply mju_dense2Band(B, H, nTotal, nBand, nDense); mju_bandMulMatVec(res1, B, vec, nTotal, nBand, nDense, /*nVec=*/1, /*flg_sym=*/1); // expect numerical equality mjtNum eps = MjTol(1e-12, 5e-5); EXPECT_THAT(AsVector(res, nTotal), Pointwise(MjNear(eps, eps), AsVector(res1, nTotal))); mju_free(res1); mju_free(res); mju_free(vec); mju_free(H); mju_free(B); } } } // test banded factorization and vector product with factor TEST_F(BandMatrixTest, Factorization) { int seed = 1; int nTotal = 8; for (int nBand : {1, 3}) { for (int nDense : {0, 2}) { for (mjtNum diagadd : {0.0, 1.0}) { for (int diagmul : {0.0, 1.3}) { // allocate int nB = (nTotal - nDense) * nBand + nDense * nTotal; mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB); int nH = nTotal * nTotal; mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); mjtNum* H1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); mjtNum* vec = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); mjtNum* res = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); mjtNum* res1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); // make random banded matrix, dense representation // add regularizer to ensure PD randomBanded(H, nTotal, nBand, nDense, seed++, vec, /*reg=*/nTotal); // convert to banded mju_dense2Band(B, H, nTotal, nBand, nDense); // apply diagadd and diagmul for (int i = 0; i < nTotal; i++) { H[nTotal * i + i] += diagadd + diagmul * H[nTotal * i + i]; } // in-place dense factorization int rank = mju_cholFactor(H, nTotal, /*mindiag=*/0); // expect factorization to have succeeded EXPECT_EQ(rank, nTotal); // banded factorization mjtNum minDiag = mju_cholFactorBand(B, nTotal, nBand, nDense, diagadd, diagmul); // expect factorization to have succeeded EXPECT_GT(minDiag, 0); // convert back to dense, lower triangle only mju_band2Dense(H1, B, nTotal, nBand, nDense, /*flg_sym=*/0); // zero upper triangle of H (unused) for (int i = 0; i < nTotal - 1; i++) { mju_zero(H + nTotal * i + i + 1, nTotal - i - 1); } // expect numerical equality mjtNum eps = MjTol(1e-12, 5e-5); EXPECT_THAT(AsVector(H, nH), Pointwise(MjNear(eps, eps), AsVector(H1, nH))); // multiply dense mju_mulMatVec(res, H, vec, nTotal, nTotal); // multiply sparse, only lower triangle mju_bandMulMatVec(res1, B, vec, nTotal, nBand, nDense, /*nVec=*/1, /*flg_sym=*/0); // expect numerical equality EXPECT_THAT(AsVector(res, nTotal), Pointwise(MjNear(eps, eps), AsVector(res1, nTotal))); mju_free(res1); mju_free(res); mju_free(vec); mju_free(H1); mju_free(H); mju_free(B); } } } } } // test banded solve TEST_F(BandMatrixTest, Solve) { int seed = 1; int nTotal = 8; for (int nBand : {1, 3}) { for (int nDense : {0, 2}) { // allocate int nB = (nTotal - nDense) * nBand + nDense * nTotal; mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB); int nH = nTotal * nTotal; mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); mjtNum* H1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH); mjtNum* vec = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); mjtNum* res = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); mjtNum* res1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal); // make random banded matrix, dense representation // add regularizer to ensure PD randomBanded(H, nTotal, nBand, nDense, seed++, vec, /*reg=*/nTotal); // convert to banded mju_dense2Band(B, H, nTotal, nBand, nDense); // in-place dense factorization int rank = mju_cholFactor(H, nTotal, /*mindiag=*/0); // expect factorization to have succeeded EXPECT_EQ(rank, nTotal); // banded factorization mjtNum minDiag = mju_cholFactorBand(B, nTotal, nBand, nDense, /*diagadd=*/0, /*diagmul=*/0); // expect factorization to have succeeded EXPECT_GT(minDiag, 0); // convert back to dense, lower triangle only mju_band2Dense(H1, B, nTotal, nBand, nDense, /*flg_sym=*/0); // solve with dense mju_cholSolve(res, H, vec, nTotal); // solve with banded mju_cholSolveBand(res1, B, vec, nTotal, nBand, nDense); // expect numerical equality mjtNum eps = MjTol(1e-12, 5e-5); EXPECT_THAT(AsVector(res, nTotal), Pointwise(MjNear(eps, eps), AsVector(res1, nTotal))); mju_free(res1); mju_free(res); mju_free(vec); mju_free(H1); mju_free(H); mju_free(B); } } } using EngineUtilSolveTest = MujocoTest; TEST_F(EngineUtilSolveTest, MjuCholFactorSymbolic) { MjModelPtr model = LoadModelFromString(""); MjDataPtr d = MakeData(model); // Test matrix (upper triangular, representing symmetric matrix): // [10 1 2 3] // [ 0 10 0 0] // [ 0 0 10 1] // [ 0 0 0 10] // // mju_cholFactorSymbolic reads entries where col >= row int n = 4; mjtNum H[16] = {10, 1, 2, 3, 0, 10, 0, 0, 0, 0, 10, 1, 0, 0, 0, 10}; // convert to sparse mjtNum sparseH[16]; int H_rownnz[4], H_rowadr[4], H_colind[16]; mju_dense2sparse(sparseH, H, n, n, H_rownnz, H_rowadr, H_colind, 16); // phase 1: counting int L_rownnz[4], L_rowadr[4]; int LT_rownnz[4], LT_rowadr[4]; int nnz = mju_cholFactorSymbolic(nullptr, L_rownnz, L_rowadr, nullptr, LT_rownnz, LT_rowadr, nullptr, H_rownnz, H_rowadr, H_colind, n, d.get()); // verify counting phase outputs EXPECT_EQ(nnz, 8); // L (CSR) structure for reverse Cholesky (rows filled in reverse order) EXPECT_THAT(AsVector(L_rownnz, 4), ElementsAre(1, 2, 2, 3)); EXPECT_THAT(AsVector(L_rowadr, 4), ElementsAre(0, 1, 3, 5)); // LT (CSC) structure: transpose of L EXPECT_THAT(AsVector(LT_rownnz, 4), ElementsAre(4, 1, 2, 1)); EXPECT_THAT(AsVector(LT_rowadr, 4), ElementsAre(0, 4, 5, 7)); // phase 2: filling int L_colind[8], LT_colind[8], LT_pos[8]; mju_cholFactorSymbolic(L_colind, L_rownnz, L_rowadr, LT_colind, LT_rownnz, LT_rowadr, LT_pos, H_rownnz, H_rowadr, H_colind, n, d.get()); // verify L_colind EXPECT_THAT(AsVector(L_colind, 8), ElementsAre(0, 0, 1, 0, 2, 0, 2, 3)); // Explanation (reverse Cholesky builds from bottom to top): // Row 0 (1 entry): diagonal 0 // Row 1 (2 entries): col 0, then diagonal 1 // Row 2 (2 entries): col 0, then diagonal 2 // Row 3 (3 entries): col 0, col 2, then diagonal 3 // verify LT_colind: transpose of L EXPECT_THAT(AsVector(LT_colind, 8), ElementsAre(0, 1, 2, 3, 1, 2, 3, 3)); // Explanation: // Column 0 (4 entries): rows 0, 1, 2, 3 (all have L[row, 0] != 0) // Column 1 (1 entry): row 1 // Column 2 (2 entries): rows 2, 3 // Column 3 (1 entry): row 3 // verify LT_pos: for each entry in LT, should point to correct position in L for (int c = 0; c < n; c++) { int adr = LT_rowadr[c]; for (int k = 0; k < LT_rownnz[c]; k++) { int L_idx = LT_pos[adr + k]; EXPECT_EQ(L_colind[L_idx], c) << "LT_pos mismatch at column " << c << ", entry " << k; } } } // Test for mju_cholUpdate: rank-one Cholesky update L*L' +/- x*x' // Verifies that after applying the update, reconstructing H = L*L' produces // the expected result H_original +/- x*x'. TEST_F(EngineUtilSolveTest, MjuCholUpdate) { std::mt19937_64 rng; rng.seed(42); std::normal_distribution dist(0, 1); for (int n : {4, 6, 8}) { vector H(n * n); vector H_expected(n * n); vector L_dense(n * n); vector sqrtH(n * n); vector x(n); vector x_copy(n); vector H_reconstructed(n * n); for (int flg_plus : {0, 1}) { // generate random lower-triangular matrix for sqrtH mju_zero(sqrtH.data(), n * n); for (int i = 0; i < n; i++) { for (int j = 0; j <= i; j++) { sqrtH[n * i + j] = dist(rng); } sqrtH[n * i + i] = mju_abs(sqrtH[n * i + i]) + n; } // create SPD matrix H = sqrtH * sqrtH' (forward-order Cholesky: L*L') for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { mjtNum sum = 0; for (int k = 0; k <= mju_min(i, j); k++) { sum += sqrtH[n * i + k] * sqrtH[n * j + k]; } H[n * i + j] = sum; } } // generate random update vector x for (int i = 0; i < n; i++) { x[i] = dist(rng) * 0.5; } // compute expected result: H_expected = H +/- x*x' mju_copy(H_expected.data(), H.data(), n * n); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (flg_plus) { H_expected[i * n + j] += x[i] * x[j]; } else { H_expected[i * n + j] -= x[i] * x[j]; } } } // test using dense Cholesky (forward-order: L*L') mju_copy(L_dense.data(), H.data(), n * n); // dense factorization int rank_factor = mju_cholFactor(L_dense.data(), n, 0); EXPECT_EQ(rank_factor, n) << "Initial factorization failed"; // make copy of x for update (it's modified in-place) mju_copy(x_copy.data(), x.data(), n); // apply dense rank-one update int rank_update = mju_cholUpdate(L_dense.data(), x_copy.data(), n, flg_plus); EXPECT_EQ(rank_update, n) << "Dense update rank loss for n=" << n << ", flg_plus=" << flg_plus; // zero out upper triangle (Cholesky only uses lower triangle) for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { L_dense[i * n + j] = 0; } } // reconstruct H from L*L' and compare to expected mju_mulMatMatT(H_reconstructed.data(), L_dense.data(), L_dense.data(), n, n, n); // compare mjtNum eps = MjTol(1e-8, 5e-4); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { EXPECT_NEAR(H_reconstructed[i * n + j], H_expected[i * n + j], eps) << "Dense mismatch at (" << i << "," << j << ") for n=" << n << ", flg_plus=" << flg_plus; } } } } } // Test for mju_cholUpdateSparse: sparse rank-one Cholesky update L'*L +/- x*x' // Uses sparse reverse Cholesky factorization and sparse rank-one update. TEST_F(EngineUtilSolveTest, MjuCholUpdateSparse) { MjModelPtr model = LoadModelFromString(""); MjDataPtr d = MakeData(model); std::mt19937_64 rng; rng.seed(123); std::normal_distribution dist(0, 1); for (int n : {4, 6, 8}) { int max_nnz = n * (n + 1) / 2; vector H(n * n); vector H_lower(n * n); vector H_expected(n * n); vector sqrtH(n * n); vector L_sparse(max_nnz); vector rownnz(n); vector rowadr(n); vector colind(max_nnz); vector x(n); vector x_sparse(n); vector x_ind(n); vector L_sparse_dense(n * n); vector H_sparse_reconstructed(n * n); for (int flg_plus : {0, 1}) { // generate random lower-triangular matrix for sqrtH mju_zero(sqrtH.data(), n * n); for (int i = 0; i < n; i++) { for (int j = 0; j <= i; j++) { sqrtH[n * i + j] = dist(rng); } sqrtH[n * i + i] = mju_abs(sqrtH[n * i + i]) + n; } // create SPD matrix H = sqrtH * sqrtH' (forward-order: L*L') for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { mjtNum sum = 0; for (int k = 0; k <= mju_min(i, j); k++) { sum += sqrtH[n * i + k] * sqrtH[n * j + k]; } H[n * i + j] = sum; } } // generate random update vector x for (int i = 0; i < n; i++) { x[i] = dist(rng) * 0.5; } // compute expected result: H_expected = H +/- x*x' mju_copy(H_expected.data(), H.data(), n * n); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (flg_plus) { H_expected[i * n + j] += x[i] * x[j]; } else { H_expected[i * n + j] -= x[i] * x[j]; } } } // copy H to H_lower and zero upper triangle (sparse expects lower only) mju_copy(H_lower.data(), H.data(), n * n); for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { H_lower[i * n + j] = 0; } } // convert lower-triangular H to sparse format mju_dense2sparse(L_sparse.data(), H_lower.data(), n, n, rownnz.data(), rowadr.data(), colind.data(), max_nnz); // prepare sparse update vector (all elements, fully dense) int x_nnz = n; mju_copy(x_sparse.data(), x.data(), n); for (int i = 0; i < n; i++) { x_ind[i] = i; } // sparse Cholesky factorization (reverse-order: L'*L) mju_cholFactorSparse(L_sparse.data(), n, 0, rownnz.data(), rowadr.data(), colind.data(), d.get()); // apply sparse rank-one update int rank_sparse = mju_cholUpdateSparse( L_sparse.data(), x_sparse.data(), n, flg_plus, rownnz.data(), rowadr.data(), colind.data(), x_nnz, x_ind.data(), d.get()); EXPECT_EQ(rank_sparse, n) << "Sparse update rank loss for n=" << n << ", flg_plus=" << flg_plus; // reconstruct H from L'*L and compare to expected mju_sparse2dense(L_sparse_dense.data(), L_sparse.data(), n, n, rownnz.data(), rowadr.data(), colind.data()); mju_mulMatTMat(H_sparse_reconstructed.data(), L_sparse_dense.data(), L_sparse_dense.data(), n, n, n); // compare mjtNum eps = MjTol(1e-8, 5e-4); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { EXPECT_NEAR(H_sparse_reconstructed[i * n + j], H_expected[i * n + j], eps) << "Sparse mismatch at (" << i << "," << j << ") for n=" << n << ", flg_plus=" << flg_plus; } } } } } // Test that mju_cholFactorSymbolic + mju_cholFactorNumeric produces identical // results to the reference implementation mju_cholFactorSparse TEST_F(EngineUtilSolveTest, CholFactorSymbolicNumeric) { MjModelPtr model = LoadModelFromString(""); MjDataPtr d = MakeData(model); // test matrix with fill-in: upper triangle structure int n = 4; mjtNum H[16] = {10, 1, 2, 3, 1, 10, 0, 0, 2, 0, 10, 1, 3, 0, 1, 10}; // convert to sparse (lower triangle only) mjtNum sparseH[16]; int H_rownnz[4], H_rowadr[4], H_colind[16]; mju_dense2sparse(sparseH, H, n, n, H_rownnz, H_rowadr, H_colind, 16); // transpose for upper triangle (needed by cholFactorSymbolic) int HT_rownnz[4], HT_rowadr[4], HT_colind[16]; mju_transposeSparse(nullptr, nullptr, n, n, HT_rownnz, HT_rowadr, HT_colind, nullptr, H_rownnz, H_rowadr, H_colind); // count fill-in (also computes LT structure) int L_rownnz[4], L_rowadr[4]; int LT_rownnz[4], LT_rowadr[4]; int nnz = mju_cholFactorSymbolic(nullptr, L_rownnz, L_rowadr, nullptr, LT_rownnz, LT_rowadr, nullptr, HT_rownnz, HT_rowadr, HT_colind, n, d.get()); // filling phase: compute L_colind, LT_colind, and LT_pos int L_colind[16], LT_colind[16], LT_pos[16]; mju_cholFactorSymbolic(L_colind, L_rownnz, L_rowadr, LT_colind, LT_rownnz, LT_rowadr, LT_pos, HT_rownnz, HT_rowadr, HT_colind, n, d.get()); // verify LT structure matches what we'd get from a separate transpose int LT_rownnz_ref[4], LT_rowadr_ref[4], LT_colind_ref[16]; mju_transposeSparse(nullptr, nullptr, n, n, LT_rownnz_ref, LT_rowadr_ref, LT_colind_ref, nullptr, L_rownnz, L_rowadr, L_colind); // LT structure should match EXPECT_THAT(AsVector(LT_rownnz, 4), ElementsAre(LT_rownnz_ref[0], LT_rownnz_ref[1], LT_rownnz_ref[2], LT_rownnz_ref[3])); EXPECT_THAT(AsVector(LT_rowadr, 4), ElementsAre(LT_rowadr_ref[0], LT_rowadr_ref[1], LT_rowadr_ref[2], LT_rowadr_ref[3])); // verify LT_colind and LT_pos match for (int r = 0; r < n; r++) { int adr = LT_rowadr[r]; for (int k = 0; k < LT_rownnz[r]; k++) { int L_idx = LT_pos[adr + k]; // index in L array // verify L_colind at this position is indeed r EXPECT_EQ(L_colind[L_idx], r) << "LT_pos mismatch at LT[" << r << ", " << k << "]"; } } // numeric factorization using new function mjtNum L_new[16]; int rank_new = mju_cholFactorNumeric( L_new, n, 1e-10, L_rownnz, L_rowadr, L_colind, LT_rownnz, LT_rowadr, LT_colind, LT_pos, sparseH, H_rownnz, H_rowadr, H_colind, d.get()); // reference implementation: copy sparse H into L_ref, then factor in-place mjtNum L_ref[16]; int L_ref_rownnz[4], L_ref_colind[16]; for (int r = 0; r < n; r++) { int nnz_r = H_rownnz[r]; // count lower triangle elements for this row int lower_nnz = 0; for (int i = 0; i < nnz_r; i++) { if (H_colind[H_rowadr[r] + i] <= r) { L_ref[L_rowadr[r] + lower_nnz] = sparseH[H_rowadr[r] + i]; L_ref_colind[L_rowadr[r] + lower_nnz] = H_colind[H_rowadr[r] + i]; lower_nnz++; } } L_ref_rownnz[r] = lower_nnz; } int rank_ref = mju_cholFactorSparse(L_ref, n, 1e-10, L_ref_rownnz, L_rowadr, L_ref_colind, d.get()); // compare results EXPECT_EQ(rank_new, rank_ref); EXPECT_EQ(rank_new, n); // compare L values mjtNum eps = MjTol(1e-10, 5e-5); for (int i = 0; i < nnz; i++) { EXPECT_NEAR(L_new[i], L_ref[i], eps) << "mismatch at index " << i; } } // ----------------------------- dense LU -------------------------------------- using DenseLUTest = MujocoTest; // factor identity, solve recovers b exactly TEST_F(DenseLUTest, Identity) { constexpr int n = 4; mjtNum A[n * n] = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, }; int pivot[n]; mjtNum b[n] = {1, 2, 3, 4}; mjtNum x[n]; EXPECT_EQ(mju_factorLU(A, n, pivot), 1); mju_solveLU(x, A, b, pivot, n); for (int i = 0; i < n; i++) { EXPECT_MJTNUM_EQ(x[i], b[i]); } } // 3x3 with known solution TEST_F(DenseLUTest, SmallKnown) { constexpr int n = 3; // A = [2 1 1; 4 3 3; 8 7 9], b = [1; 1; 1] // solution: x = [1; -1; 0] (verified: A*x = [2-1; 4-3; 8-7] = [1;1;1]) mjtNum A[n * n] = { 2, 1, 1, 4, 3, 3, 8, 7, 9, }; int pivot[n]; mjtNum b[n] = {1, 1, 1}; mjtNum x[n]; EXPECT_EQ(mju_factorLU(A, n, pivot), 1); mju_solveLU(x, A, b, pivot, n); mjtNum eps = MjTol(1e-14, 1e-6); EXPECT_NEAR(x[0], 1, eps); EXPECT_NEAR(x[1], -1, eps); EXPECT_NEAR(x[2], 0, eps); } // random SPD matrices: compare LU solve against Cholesky solve TEST_F(DenseLUTest, RandomSPD) { std::mt19937_64 rng; rng.seed(7); std::normal_distribution dist(0, 1); for (int n : {4, 8, 16}) { vector sqrtH(n * n); vector A(n * n); vector A_chol(n * n); vector b(n); vector x_lu(n); vector x_chol(n); vector pivot(n); // generate random SPD matrix for (int i = 0; i < n * n; i++) sqrtH[i] = dist(rng); mju_mulMatTMat(A.data(), sqrtH.data(), sqrtH.data(), n, n, n); // add diagonal regularizer for (int i = 0; i < n; i++) A[i * n + i] += n; // generate random rhs for (int i = 0; i < n; i++) b[i] = dist(rng); // solve with Cholesky mju_copy(A_chol.data(), A.data(), n * n); int rank = mju_cholFactor(A_chol.data(), n, 0); EXPECT_EQ(rank, n); mju_cholSolve(x_chol.data(), A_chol.data(), b.data(), n); // solve with LU int ok = mju_factorLU(A.data(), n, pivot.data()); EXPECT_EQ(ok, 1); mju_solveLU(x_lu.data(), A.data(), b.data(), pivot.data(), n); // compare mjtNum eps = MjTol(1e-15, 1e-7); EXPECT_THAT(AsVector(x_lu.data(), n), Pointwise(MjNear(eps, eps), AsVector(x_chol.data(), n))); } } // random non-symmetric matrices: verify A*x == b TEST_F(DenseLUTest, RandomGeneral) { std::mt19937_64 rng; rng.seed(42); std::normal_distribution dist(0, 1); for (int n : {3, 5, 10, 20}) { vector A(n * n); vector A_orig(n * n); vector b(n); vector x(n); vector Ax(n); vector pivot(n); // random non-symmetric matrix with diagonal dominance for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { A[i * n + j] = dist(rng); } A[i * n + i] += 2 * n; } mju_copy(A_orig.data(), A.data(), n * n); // random rhs for (int i = 0; i < n; i++) b[i] = dist(rng); // factor and solve int ok = mju_factorLU(A.data(), n, pivot.data()); EXPECT_EQ(ok, 1); mju_solveLU(x.data(), A.data(), b.data(), pivot.data(), n); // verify: A_orig * x == b mju_mulMatVec(Ax.data(), A_orig.data(), x.data(), n, n); mjtNum eps = MjTol(1e-14, 1e-5); EXPECT_THAT(AsVector(Ax.data(), n), Pointwise(MjNear(eps, eps), AsVector(b.data(), n))); } } // near-singular matrix returns 0 TEST_F(DenseLUTest, Singular) { constexpr int n = 3; // all zeros: maximally singular mjtNum A[n * n] = {0}; int pivot[n]; EXPECT_EQ(mju_factorLU(A, n, pivot), 0); } // 6x6 specialization: identical results to the generic version TEST_F(DenseLUTest, Fixed6MatchesGeneric) { constexpr int n = 6; std::mt19937_64 rng; rng.seed(3); std::normal_distribution dist(0, 1); for (int trial = 0; trial < 100; trial++) { mjtNum A[n * n], A6[n * n], b[n], x[n], x6[n]; int pivot[n], pivot6[n]; // random asymmetric matrix; first trial zeroes the diagonal to force // pivoting for (int i = 0; i < n * n; i++) { A[i] = dist(rng); } if (trial == 0) { for (int i = 0; i < n; i++) { A[i * n + i] = 0; } } for (int i = 0; i < n; i++) { b[i] = dist(rng); } mju_copy(A6, A, n * n); // same algorithm; results agree to rounding (not bitwise: the compiler may // contract the unrolled fixed-size version differently) int rank = mju_factorLU(A, n, pivot); int rank6 = mju_factorLU6(A6, pivot6); ASSERT_EQ(rank, rank6); if (!rank) { continue; } for (int i = 0; i < n; i++) { EXPECT_EQ(pivot[i], pivot6[i]); } for (int i = 0; i < n * n; i++) { EXPECT_NEAR(A[i], A6[i], MjTol(1e-13, 1e-4)); } mju_solveLU(x, A, b, pivot, n); mju_solveLU6(x6, A6, b, pivot6); for (int i = 0; i < n; i++) { EXPECT_NEAR(x[i], x6[i], MjTol(1e-12, 1e-3)); } } } // nonsingular matrix with all-zero diagonal: requires pivoting TEST_F(DenseLUTest, ZeroDiagonal) { constexpr int n = 3; // A = [0 2 1; 1 0 3; 4 1 0], det(A) = 25 mjtNum A[n * n] = { 0, 2, 1, 1, 0, 3, 4, 1, 0, }; mjtNum A_orig[n * n]; mju_copy(A_orig, A, n * n); int pivot[n]; mjtNum b[n] = {1, 2, 3}; mjtNum x[n], Ax[n]; EXPECT_EQ(mju_factorLU(A, n, pivot), 1); mju_solveLU(x, A, b, pivot, n); // verify A_orig * x == b mju_mulMatVec(Ax, A_orig, x, n, n); mjtNum eps = MjTol(1e-14, 1e-6); EXPECT_THAT(AsVector(Ax, n), Pointwise(MjNear(eps, eps), AsVector(b, n))); } } // namespace } // namespace mujoco