Add tests for mju_cholUpdate and mju_cholUpdateSparse.

PiperOrigin-RevId: 845229292
Change-Id: Ide1406f85e49fe5a5b339bedff7ff6dd8099e5e8
This commit is contained in:
Yuval Tassa
2025-12-16 05:58:50 -08:00
committed by Copybara-Service
parent 2b9940bc67
commit 08b4b4144d
2 changed files with 221 additions and 7 deletions
+5 -6
View File
@@ -33,9 +33,8 @@ MJAPI int mju_cholUpdate(mjtNum* mat, mjtNum* x, int n, int flg_plus);
// sparse reverse-order Cholesky decomposition: mat = L'*L; return 'rank'
// mat must be lower-triangular, have preallocated space for fill-in
int mju_cholFactorSparse(mjtNum* mat, int n, mjtNum mindiag,
int* rownnz, const int* rowadr, int* colind,
mjData* d);
MJAPI int mju_cholFactorSparse(mjtNum* mat, int n, mjtNum mindiag,
int* rownnz, const int* rowadr, int* colind, mjData* d);
// precount row non-zeros of reverse-Cholesky factor L, return total
MJAPI int mju_cholFactorCount(int* L_rownnz, const int* rownnz, const int* rowadr,
@@ -47,9 +46,9 @@ void mju_cholSolveSparse(mjtNum* res, const mjtNum* mat, const mjtNum* vec, int
// sparse reverse-order Cholesky rank-one update: L'*L +/i x*x'; return rank
// x is sparse, change in sparsity pattern of mat is not allowed
int mju_cholUpdateSparse(mjtNum* mat, mjtNum* x, int n, int flg_plus,
const int* rownnz, const int* rowadr, const int* colind,
int x_nnz, int* x_ind, mjData* d);
MJAPI int mju_cholUpdateSparse(mjtNum* mat, mjtNum* x, int n, int flg_plus,
const int* rownnz, const int* rowadr, const int* colind,
int x_nnz, int* x_ind, mjData* d);
// band-dense Cholesky decomposition
// returns minimum value in the factorized diagonal, or 0 if rank-deficient
+216 -1
View File
@@ -17,10 +17,11 @@
#include "src/engine/engine_util_solve.h"
#include <cstddef>
#include <iomanip>
#include <iostream>
#include <random>
#include <iomanip>
#include <string>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
@@ -37,6 +38,7 @@ using ::testing::Pointwise;
using ::testing::DoubleNear;
using ::testing::ElementsAre;
using ::std::string;
using ::std::vector;
using ::std::setw;
using QCQP2Test = MujocoTest;
@@ -723,5 +725,218 @@ TEST_F(EngineUtilSolveTest, MjuCholFactorNNZ) {
mj_deleteModel(model);
}
// 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<double> dist(0, 1);
for (int n : {4, 6, 8}) {
vector<mjtNum> H(n * n);
vector<mjtNum> H_expected(n * n);
vector<mjtNum> L_dense(n * n);
vector<mjtNum> sqrtH(n * n);
vector<mjtNum> x(n);
vector<mjtNum> x_copy(n);
vector<mjtNum> 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 = 1e-8;
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) {
mjModel* model = LoadModelFromString("<mujoco/>");
mjData* d = mj_makeData(model);
std::mt19937_64 rng;
rng.seed(123);
std::normal_distribution<double> dist(0, 1);
for (int n : {4, 6, 8}) {
int max_nnz = n * (n + 1) / 2;
vector<mjtNum> H(n * n);
vector<mjtNum> H_lower(n * n);
vector<mjtNum> H_expected(n * n);
vector<mjtNum> sqrtH(n * n);
vector<mjtNum> L_sparse(max_nnz);
vector<int> rownnz(n);
vector<int> rowadr(n);
vector<int> colind(max_nnz);
vector<mjtNum> x(n);
vector<mjtNum> x_sparse(n);
vector<int> x_ind(n);
vector<mjtNum> L_sparse_dense(n * n);
vector<mjtNum> 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);
// 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);
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 = 1e-8;
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;
}
}
}
}
mj_deleteData(d);
mj_deleteModel(model);
}
} // namespace
} // namespace mujoco