From 88fe4004100ee1e84325176b886512aa6f13728b Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sat, 6 Sep 2025 19:56:57 -0400 Subject: [PATCH 01/22] feat: add matrix helpers --- build.rs | 7 +- src/constants.rs | 12 +- src/lib.rs | 2 + src/matrices_and_arrays.rs | 436 +++++++++++++++---------------------- src/matrix/mod.rs | 169 ++++++++++++++ src/patterns.rs | 35 ++- 6 files changed, 369 insertions(+), 292 deletions(-) create mode 100644 src/matrix/mod.rs diff --git a/build.rs b/build.rs index 4c52a2a..5993488 100644 --- a/build.rs +++ b/build.rs @@ -85,7 +85,7 @@ fn main() { let function = function.clone(); // Pull out basic info let name = function.name; - if !name.starts_with("anon") && !name.starts_with("_") && !name.starts_with("$CONSTANTS$"){ + if !name.starts_with("anon") && !name.starts_with("_") && !name.starts_with("$CONSTANTS$") { let signature = function .signature .replace("Result<", "") @@ -225,3 +225,8 @@ mod functions { #[cfg(feature = "metadata")] pub use functions::*; + +#[cfg(feature = "metadata")] +pub mod matrix { + include!("src/matrix/mod.rs"); +} diff --git a/src/constants.rs b/src/constants.rs index de4c276..8641571 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -7,15 +7,15 @@ pub mod constant_definitions { // The ratio of a circle's circumference to its diameter. #[allow(non_upper_case_globals)] - pub const pi: FLOAT = 3.14159265358979323846264338327950288; + pub const pi: FLOAT = 3.141_592_653_589_793_238_462_643_383_279_502_88; //Speed of light in meters per second (m/s). #[allow(non_upper_case_globals)] - pub const c: FLOAT = 299792458.0; + pub const c: FLOAT = 299_792_458.0; // Euler's number. #[allow(non_upper_case_globals)] - pub const e: FLOAT = 2.71828182845904523536028747135266250; + pub const e: FLOAT = 2.718_281_828_459_045_235_360_287_471_352_662_50; // Acceleration due to gravity on Earth in meters per second per second (m/s^2). #[allow(non_upper_case_globals)] @@ -23,14 +23,14 @@ pub mod constant_definitions { // The Planck constant in Joules per Hertz (J/Hz) #[allow(non_upper_case_globals)] - pub const h: FLOAT = 6.62607015e-34; + pub const h: FLOAT = 6.626_070_15e-34; // The golden ratio #[allow(non_upper_case_globals)] - pub const phi: FLOAT = 1.61803398874989484820; + pub const phi: FLOAT = 1.618_033_988_749_894_848_20; // Newtonian gravitational constant - pub const G: FLOAT = 6.6743015e-11; + pub const G: FLOAT = 6.674_301_5e-11; /// Physical constants useful for science. /// ### `pi: FLOAT` diff --git a/src/lib.rs b/src/lib.rs index 48228b3..63098e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ mod patterns; pub use patterns::*; +pub mod matrix; +pub use matrix::{RhaiMatrix, RhaiVector}; use rhai::{def_package, packages::Package, plugin::*, Engine, EvalAltResult}; mod matrices_and_arrays; pub use matrices_and_arrays::matrix_functions; diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index c7f845b..512ba53 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -1,17 +1,15 @@ -use nalgebralib::{Dyn, OMatrix}; use rhai::plugin::*; #[export_module] pub mod matrix_functions { + #[cfg(feature = "nalgebra")] + use crate::matrix::{RhaiMatrix, RhaiVector}; use crate::{ array_to_vec_float, if_int_convert_to_float_and_do, if_int_do_else_if_array_do, if_list_do, if_matrix_convert_to_vec_array_and_do, }; #[cfg(feature = "nalgebra")] - use crate::{ - if_matrices_and_compatible_convert_to_vec_array_and_do, if_matrix_do, - omatrix_to_vec_dynamic, ovector_to_vec_dynamic, FOIL, - }; + use crate::{if_matrices_and_compatible_convert_to_vec_array_and_do, if_matrix_do, FOIL}; #[cfg(feature = "nalgebra")] use nalgebralib::DMatrix; use rhai::{Array, Dynamic, EvalAltResult, Map, Position, FLOAT, INT}; @@ -40,26 +38,16 @@ pub mod matrix_functions { #[cfg(feature = "nalgebra")] #[rhai_fn(name = "inv", return_raw, pure)] pub fn invert_matrix(matrix: &mut Array) -> Result> { - if_matrix_convert_to_vec_array_and_do(matrix, |matrix_as_vec| { - let dm = DMatrix::from_fn(matrix_as_vec.len(), matrix_as_vec[0].len(), |i, j| { - if matrix_as_vec[0][0].is_float() { - matrix_as_vec[i][j].as_float().unwrap() - } else { - matrix_as_vec[i][j].as_int().unwrap() as FLOAT - } - }); - - // Try to invert - let dm = dm.try_inverse(); - - dm.map(omatrix_to_vec_dynamic).ok_or_else(|| { + let dm = RhaiMatrix::from_array(matrix.clone()).to_dmatrix()?; + dm.try_inverse() + .map(|m| RhaiMatrix::from_dmatrix(&m).to_array()) + .ok_or_else(|| { EvalAltResult::ErrorArithmetic( "Matrix cannot be inverted".to_string(), Position::NONE, ) .into() }) - }) } /// Calculate the eigenvalues and eigenvectors for a matrix. Specifically, the output is an @@ -79,73 +67,58 @@ pub mod matrix_functions { #[cfg(feature = "nalgebra")] #[rhai_fn(name = "eigs", return_raw, pure)] pub fn matrix_eigs_alt(matrix: &mut Array) -> Result> { - if_matrix_convert_to_vec_array_and_do(matrix, |matrix_as_vec| { - // Convert vec_array to omatrix - let mut dm = DMatrix::from_fn(matrix_as_vec.len(), matrix_as_vec[0].len(), |i, j| { - if matrix_as_vec[0][0].is_float() { - matrix_as_vec[i][j].as_float().unwrap() - } else { - matrix_as_vec[i][j].as_int().unwrap() as FLOAT - } - }); - - // Grab shape for later - let dms = dm.shape().1; - - // Get teh eigenvalues - let eigenvalues = dm.complex_eigenvalues(); - - // Iterate through eigenvalues to get eigenvectors - let mut imaginary_values = vec![Dynamic::from_float(1.0); 0]; - let mut real_values = vec![Dynamic::from_float(1.0); 0]; - let mut residuals = vec![Dynamic::from_float(1.0); 0]; - let mut eigenvectors = DMatrix::from_element(dms, 0, 0.0); - for (idx, ev) in eigenvalues.iter().enumerate() { - // Eigenvalue components - imaginary_values.push(Dynamic::from_float(ev.im)); - real_values.push(Dynamic::from_float(ev.re)); - - // Get eigenvector - let mut A = dm.clone() - DMatrix::from_diagonal_element(dms, dms, ev.re); - A = A.insert_column(0, 0.0); - A = A.insert_row(0, 0.0); - A[(0, idx + 1)] = 1.0; - let mut b = DMatrix::from_element(dms + 1, 1, 0.0); - b[(0, 0)] = 1.0; - let eigenvector = A - .svd(true, true) - .solve(&b, 1e-10) - .unwrap() - .remove_rows(0, 1) - .normalize(); - - // Verify solution - residuals.push(Dynamic::from_float( - (dm.clone() * eigenvector.clone() - ev.re * eigenvector.clone()).amax(), - )); - - eigenvectors.extend(eigenvector.column_iter()); - } + let dm = RhaiMatrix::from_array(matrix.clone()).to_dmatrix()?; + + let dms = dm.shape().1; + + let eigenvalues = dm.complex_eigenvalues(); + + let mut imaginary_values = vec![Dynamic::from_float(1.0); 0]; + let mut real_values = vec![Dynamic::from_float(1.0); 0]; + let mut residuals = vec![Dynamic::from_float(1.0); 0]; + let mut eigenvectors = DMatrix::from_element(dms, 0, 0.0); + for (idx, ev) in eigenvalues.iter().enumerate() { + imaginary_values.push(Dynamic::from_float(ev.im)); + real_values.push(Dynamic::from_float(ev.re)); + + let mut a = dm.clone() - DMatrix::from_diagonal_element(dms, dms, ev.re); + a = a.insert_column(0, 0.0); + a = a.insert_row(0, 0.0); + a[(0, idx + 1)] = 1.0; + let mut b = DMatrix::from_element(dms + 1, 1, 0.0); + b[(0, 0)] = 1.0; + let eigenvector = a + .svd(true, true) + .solve(&b, 1e-10) + .unwrap() + .remove_rows(0, 1) + .normalize(); + + residuals.push(Dynamic::from_float( + (dm.clone() * eigenvector.clone() - ev.re * eigenvector.clone()).amax(), + )); + + eigenvectors.extend(eigenvector.column_iter()); + } - let mut result = BTreeMap::new(); - let mut vid = smartstring::SmartString::new(); - vid.push_str("eigenvectors"); - result.insert( - vid, - Dynamic::from_array(omatrix_to_vec_dynamic(eigenvectors)), - ); - let mut did = smartstring::SmartString::new(); - did.push_str("real_eigenvalues"); - result.insert(did, Dynamic::from_array(real_values)); - let mut eid = smartstring::SmartString::new(); - eid.push_str("imaginary_eigenvalues"); - result.insert(eid, Dynamic::from_array(imaginary_values)); - let mut rid = smartstring::SmartString::new(); - rid.push_str("residuals"); - result.insert(rid, Dynamic::from_array(residuals)); - - Ok(result) - }) + let mut result = BTreeMap::new(); + let mut vid = smartstring::SmartString::new(); + vid.push_str("eigenvectors"); + result.insert( + vid, + Dynamic::from_array(RhaiMatrix::from_dmatrix(&eigenvectors).to_array()), + ); + let mut did = smartstring::SmartString::new(); + did.push_str("real_eigenvalues"); + result.insert(did, Dynamic::from_array(real_values)); + let mut eid = smartstring::SmartString::new(); + eid.push_str("imaginary_eigenvalues"); + result.insert(eid, Dynamic::from_array(imaginary_values)); + let mut rid = smartstring::SmartString::new(); + rid.push_str("residuals"); + result.insert(rid, Dynamic::from_array(residuals)); + + Ok(result) } /// Calculates the singular value decomposition of a matrix @@ -157,54 +130,50 @@ pub mod matrix_functions { #[cfg(feature = "nalgebra")] #[rhai_fn(name = "svd", return_raw, pure)] pub fn svd_decomp(matrix: &mut Array) -> Result> { - if_matrix_convert_to_vec_array_and_do(matrix, |matrix_as_vec| { - let dm = DMatrix::from_fn(matrix_as_vec.len(), matrix_as_vec[0].len(), |i, j| { - if matrix_as_vec[0][0].is::() { - matrix_as_vec[i][j].as_float().unwrap() - } else { - matrix_as_vec[i][j].as_int().unwrap() as FLOAT - } - }); - - // Try ot invert - let svd = nalgebralib::linalg::SVD::new(dm, true, true); - - let mut result = BTreeMap::new(); - let mut uid = smartstring::SmartString::new(); - uid.push_str("u"); - match svd.u { - Some(u) => result.insert(uid, Dynamic::from_array(omatrix_to_vec_dynamic(u))), - None => { - return Err(EvalAltResult::ErrorArithmetic( - format!("SVD decomposition cannot be computed for this matrix."), - Position::NONE, - ) - .into()); - } - }; - - let mut vid = smartstring::SmartString::new(); - vid.push_str("v"); - match svd.v_t { - Some(v) => result.insert(vid, Dynamic::from_array(omatrix_to_vec_dynamic(v))), - None => { - return Err(EvalAltResult::ErrorArithmetic( - format!("SVD decomposition cannot be computed for this matrix."), - Position::NONE, - ) - .into()); - } - }; + let dm = RhaiMatrix::from_array(matrix.clone()).to_dmatrix()?; + let svd = nalgebralib::linalg::SVD::new(dm, true, true); + + let mut result = BTreeMap::new(); + let mut u_key = smartstring::SmartString::new(); + u_key.push_str("u"); + match svd.u { + Some(u) => result.insert( + u_key, + Dynamic::from_array(RhaiMatrix::from_dmatrix(&u).to_array()), + ), + None => { + return Err(EvalAltResult::ErrorArithmetic( + "SVD decomposition cannot be computed for this matrix.".to_string(), + Position::NONE, + ) + .into()); + } + }; + + let mut v_key = smartstring::SmartString::new(); + v_key.push_str("v"); + match svd.v_t { + Some(v) => result.insert( + v_key, + Dynamic::from_array(RhaiMatrix::from_dmatrix(&v).to_array()), + ), + None => { + return Err(EvalAltResult::ErrorArithmetic( + "SVD decomposition cannot be computed for this matrix.".to_string(), + Position::NONE, + ) + .into()); + } + }; - let mut sid = smartstring::SmartString::new(); - sid.push_str("s"); - result.insert( - sid, - Dynamic::from_array(ovector_to_vec_dynamic(svd.singular_values)), - ); + let mut s_key = smartstring::SmartString::new(); + s_key.push_str("s"); + result.insert( + s_key, + Dynamic::from_array(RhaiVector::from_dvector(&svd.singular_values).to_array()), + ); - Ok(result) - }) + Ok(result) } /// Calculates the QR decomposition of a matrix @@ -216,29 +185,25 @@ pub mod matrix_functions { #[cfg(feature = "nalgebra")] #[rhai_fn(name = "qr", return_raw, pure)] pub fn qr_decomp(matrix: &mut Array) -> Result> { - if_matrix_convert_to_vec_array_and_do(matrix, |matrix_as_vec| { - let dm = DMatrix::from_fn(matrix_as_vec.len(), matrix_as_vec[0].len(), |i, j| { - if matrix_as_vec[0][0].is::() { - matrix_as_vec[i][j].as_float().unwrap() - } else { - matrix_as_vec[i][j].as_int().unwrap() as FLOAT - } - }); - - // Try ot invert - let qr = nalgebralib::linalg::QR::new(dm); - - let mut result = BTreeMap::new(); - let mut qid = smartstring::SmartString::new(); - qid.push_str("q"); - result.insert(qid, Dynamic::from_array(omatrix_to_vec_dynamic(qr.q()))); - - let mut rid = smartstring::SmartString::new(); - rid.push_str("r"); - result.insert(rid, Dynamic::from_array(omatrix_to_vec_dynamic(qr.r()))); - - Ok(result) - }) + let dm = RhaiMatrix::from_array(matrix.clone()).to_dmatrix()?; + let qr = nalgebralib::linalg::QR::new(dm); + + let mut result = BTreeMap::new(); + let mut qid = smartstring::SmartString::new(); + qid.push_str("q"); + result.insert( + qid, + Dynamic::from_array(RhaiMatrix::from_dmatrix(&qr.q()).to_array()), + ); + + let mut rid = smartstring::SmartString::new(); + rid.push_str("r"); + result.insert( + rid, + Dynamic::from_array(RhaiMatrix::from_dmatrix(&qr.r()).to_array()), + ); + + Ok(result) } /// Calculates the QR decomposition of a matrix @@ -250,29 +215,25 @@ pub mod matrix_functions { #[cfg(feature = "nalgebra")] #[rhai_fn(name = "hessenberg", return_raw, pure)] pub fn hessenberg(matrix: &mut Array) -> Result> { - if_matrix_convert_to_vec_array_and_do(matrix, |matrix_as_vec| { - let dm = DMatrix::from_fn(matrix_as_vec.len(), matrix_as_vec[0].len(), |i, j| { - if matrix_as_vec[0][0].is::() { - matrix_as_vec[i][j].as_float().unwrap() - } else { - matrix_as_vec[i][j].as_int().unwrap() as FLOAT - } - }); - - // Try ot invert - let h = nalgebralib::linalg::Hessenberg::new(dm); - - let mut result = BTreeMap::new(); - let mut hid = smartstring::SmartString::new(); - hid.push_str("h"); - result.insert(hid, Dynamic::from_array(omatrix_to_vec_dynamic(h.h()))); - - let mut qid = smartstring::SmartString::new(); - qid.push_str("q"); - result.insert(qid, Dynamic::from_array(omatrix_to_vec_dynamic(h.q()))); - - Ok(result) - }) + let dm = RhaiMatrix::from_array(matrix.clone()).to_dmatrix()?; + let h = nalgebralib::linalg::Hessenberg::new(dm); + + let mut result = BTreeMap::new(); + let mut hid = smartstring::SmartString::new(); + hid.push_str("h"); + result.insert( + hid, + Dynamic::from_array(RhaiMatrix::from_dmatrix(&h.h()).to_array()), + ); + + let mut qid = smartstring::SmartString::new(); + qid.push_str("q"); + result.insert( + qid, + Dynamic::from_array(RhaiMatrix::from_dmatrix(&h.q()).to_array()), + ); + + Ok(result) } /// Transposes a matrix. @@ -922,37 +883,18 @@ pub mod matrix_functions { &mut matrix1.clone(), &mut matrix2.clone(), |matrix_as_vec1, matrix_as_vec2| { - let dm1 = - DMatrix::from_fn(matrix_as_vec1.len(), matrix_as_vec1[0].len(), |i, j| { - if matrix_as_vec1[0][0].is_float() { - matrix_as_vec1[i][j].as_float().unwrap() - } else { - matrix_as_vec1[i][j].as_int().unwrap() as FLOAT - } - }); - - let dm2 = - DMatrix::from_fn(matrix_as_vec2.len(), matrix_as_vec2[0].len(), |i, j| { - if matrix_as_vec2[0][0].is_float() { - matrix_as_vec2[i][j].as_float().unwrap() - } else { - matrix_as_vec2[i][j].as_int().unwrap() as FLOAT - } - }); - - // Try to multiply + let arr1: Array = matrix_as_vec1 + .into_iter() + .map(Dynamic::from_array) + .collect(); + let arr2: Array = matrix_as_vec2 + .into_iter() + .map(Dynamic::from_array) + .collect(); + let dm1 = RhaiMatrix::from_array(arr1).to_dmatrix()?; + let dm2 = RhaiMatrix::from_array(arr2).to_dmatrix()?; let mat = dm1 * dm2; - - // Turn into Array - let mut out = vec![]; - for idx in 0..mat.shape().0 { - let mut new_row = vec![]; - for jdx in 0..mat.shape().1 { - new_row.push(Dynamic::from_float(mat[(idx, jdx)])); - } - out.push(Dynamic::from_array(new_row)); - } - Ok(out) + Ok(RhaiMatrix::from_dmatrix(&mat).to_array()) }, ) } @@ -972,25 +914,17 @@ pub mod matrix_functions { &mut matrix1.clone(), &mut matrix2.clone(), |matrix_as_vec1, matrix_as_vec2| { - let dm1 = - DMatrix::from_fn(matrix_as_vec1.len(), matrix_as_vec1[0].len(), |i, j| { - if matrix_as_vec1[0][0].is_float() { - matrix_as_vec1[i][j].as_float().unwrap() - } else { - matrix_as_vec1[i][j].as_int().unwrap() as FLOAT - } - }); + let arr1: Array = matrix_as_vec1 + .into_iter() + .map(Dynamic::from_array) + .collect(); + let arr2: Array = matrix_as_vec2 + .into_iter() + .map(Dynamic::from_array) + .collect(); + let dm1 = RhaiMatrix::from_array(arr1).to_dmatrix()?; + let dm2 = RhaiMatrix::from_array(arr2).to_dmatrix()?; - let dm2 = - DMatrix::from_fn(matrix_as_vec2.len(), matrix_as_vec2[0].len(), |i, j| { - if matrix_as_vec2[0][0].is_float() { - matrix_as_vec2[i][j].as_float().unwrap() - } else { - matrix_as_vec2[i][j].as_int().unwrap() as FLOAT - } - }); - - // Try to multiple let w0 = dm1.shape().1; let w = dm1.shape().1 + dm2.shape().1; let h = dm1.shape().0; @@ -1001,17 +935,7 @@ pub mod matrix_functions { dm1[(i, j)] } }); - - // Turn into Array - let mut out = vec![]; - for idx in 0..h { - let mut new_row = vec![]; - for jdx in 0..w { - new_row.push(Dynamic::from_float(mat[(idx, jdx)])); - } - out.push(Dynamic::from_array(new_row)); - } - Ok(out) + Ok(RhaiMatrix::from_dmatrix(&mat).to_array()) }, ) } @@ -1031,25 +955,17 @@ pub mod matrix_functions { &mut matrix1.clone(), &mut matrix2.clone(), |matrix_as_vec1, matrix_as_vec2| { - let dm1 = - DMatrix::from_fn(matrix_as_vec1.len(), matrix_as_vec1[0].len(), |i, j| { - if matrix_as_vec1[0][0].is_float() { - matrix_as_vec1[i][j].as_float().unwrap() - } else { - matrix_as_vec1[i][j].as_int().unwrap() as FLOAT - } - }); + let arr1: Array = matrix_as_vec1 + .into_iter() + .map(Dynamic::from_array) + .collect(); + let arr2: Array = matrix_as_vec2 + .into_iter() + .map(Dynamic::from_array) + .collect(); + let dm1 = RhaiMatrix::from_array(arr1).to_dmatrix()?; + let dm2 = RhaiMatrix::from_array(arr2).to_dmatrix()?; - let dm2 = - DMatrix::from_fn(matrix_as_vec2.len(), matrix_as_vec2[0].len(), |i, j| { - if matrix_as_vec2[0][0].is_float() { - matrix_as_vec2[i][j].as_float().unwrap() - } else { - matrix_as_vec2[i][j].as_int().unwrap() as FLOAT - } - }); - - // Try to multiple let h0 = dm1.shape().0; let w = dm1.shape().1; let h = dm1.shape().0 + dm2.shape().0; @@ -1060,17 +976,7 @@ pub mod matrix_functions { dm1[(i, j)] } }); - - // Turn into Array - let mut out = vec![]; - for idx in 0..h { - let mut new_row = vec![]; - for jdx in 0..w { - new_row.push(Dynamic::from_float(mat[(idx, jdx)])); - } - out.push(Dynamic::from_array(new_row)); - } - Ok(out) + Ok(RhaiMatrix::from_dmatrix(&mat).to_array()) }, ) } diff --git a/src/matrix/mod.rs b/src/matrix/mod.rs new file mode 100644 index 0000000..75ee7e3 --- /dev/null +++ b/src/matrix/mod.rs @@ -0,0 +1,169 @@ +#[cfg(feature = "nalgebra")] +use nalgebralib::{DMatrix, DVector}; +use rhai::{Array, Dynamic, EvalAltResult, Position, FLOAT}; + +/// Wrapper around [`rhai::Array`] representing a matrix. +/// +/// This type provides conversions between Rhai arrays and +/// [`nalgebra::DMatrix`]. +/// +/// # Examples +/// ``` +/// use rhai::{Array, Dynamic}; +/// use rhai_sci::matrix::RhaiMatrix; +/// let raw: Array = vec![ +/// Dynamic::from_array(vec![Dynamic::from_float(1.0), Dynamic::from_float(2.0)]), +/// Dynamic::from_array(vec![Dynamic::from_float(3.0), Dynamic::from_float(4.0)]), +/// ]; +/// let matrix = RhaiMatrix::from_array(raw.clone()); +/// assert_eq!(matrix.to_array().len(), raw.len()); +/// ``` +#[derive(Clone, Debug)] +pub struct RhaiMatrix(Array); + +impl RhaiMatrix { + /// Construct a [`RhaiMatrix`] from a [`rhai::Array`]. + #[must_use] + pub fn from_array(arr: Array) -> Self { + Self(arr) + } + + /// Convert the matrix back into a [`rhai::Array`]. + #[must_use] + pub fn to_array(self) -> Array { + self.0 + } + + /// Convert the matrix into a [`nalgebra::DMatrix`]. + /// + /// # Errors + /// Returns an error if any element is non-numeric or rows have differing lengths. + /// + /// # Panics + /// Panics if an integer value cannot be represented as `FLOAT`. + #[cfg(feature = "nalgebra")] + #[allow(clippy::cast_precision_loss)] + pub fn to_dmatrix(&self) -> Result, Box> { + if self.0.is_empty() { + return Ok(DMatrix::from_element(0, 0, 0.0)); + } + let rows = self.0.len(); + let first_row = self.0[0].clone().into_array().map_err(|_| { + EvalAltResult::ErrorArithmetic( + "Matrix must contain row arrays".to_string(), + Position::NONE, + ) + })?; + let cols = first_row.len(); + let mut dm = DMatrix::zeros(rows, cols); + for (i, row_dyn) in self.0.iter().enumerate() { + let row = row_dyn.clone().into_array().map_err(|_| { + EvalAltResult::ErrorArithmetic( + "Matrix must contain row arrays".to_string(), + Position::NONE, + ) + })?; + if row.len() != cols { + return Err(EvalAltResult::ErrorArithmetic( + "Matrix rows must have equal length".to_string(), + Position::NONE, + ) + .into()); + } + for (j, val) in row.iter().enumerate() { + dm[(i, j)] = if val.is_float() { + val.as_float().unwrap() + } else if val.is_int() { + val.as_int().unwrap() as FLOAT + } else { + return Err(EvalAltResult::ErrorArithmetic( + "Matrix elements must be INT or FLOAT".to_string(), + Position::NONE, + ) + .into()); + }; + } + } + Ok(dm) + } + + /// Create a [`RhaiMatrix`] from a [`nalgebra::DMatrix`]. + #[cfg(feature = "nalgebra")] + #[must_use] + pub fn from_dmatrix(mat: &DMatrix) -> Self { + let mut rows = Vec::with_capacity(mat.nrows()); + for i in 0..mat.nrows() { + let mut row = Vec::with_capacity(mat.ncols()); + for j in 0..mat.ncols() { + row.push(Dynamic::from_float(mat[(i, j)])); + } + rows.push(Dynamic::from_array(row)); + } + Self(rows) + } +} + +/// Wrapper around [`rhai::Array`] representing a vector. +/// +/// # Examples +/// ``` +/// use rhai::{Array, Dynamic}; +/// use rhai_sci::matrix::RhaiVector; +/// let raw: Array = vec![Dynamic::from_float(1.0), Dynamic::from_float(2.0)]; +/// let vector = RhaiVector::from_array(raw.clone()); +/// assert_eq!(vector.to_array().len(), raw.len()); +/// ``` +#[derive(Clone, Debug)] +pub struct RhaiVector(Array); + +impl RhaiVector { + /// Construct a [`RhaiVector`] from a [`rhai::Array`]. + #[must_use] + pub fn from_array(arr: Array) -> Self { + Self(arr) + } + + /// Convert the vector back into a [`rhai::Array`]. + #[must_use] + pub fn to_array(self) -> Array { + self.0 + } + + /// Convert the vector into a [`nalgebra::DVector`]. + /// + /// # Errors + /// Returns an error if any element is non-numeric. + /// + /// # Panics + /// Panics if an integer value cannot be represented as `FLOAT`. + #[cfg(feature = "nalgebra")] + #[allow(clippy::cast_precision_loss)] + pub fn to_dvector(&self) -> Result, Box> { + let mut dv = DVector::zeros(self.0.len()); + for (i, val) in self.0.iter().enumerate() { + dv[i] = if val.is_float() { + val.as_float().unwrap() + } else if val.is_int() { + val.as_int().unwrap() as FLOAT + } else { + return Err(EvalAltResult::ErrorArithmetic( + "Vector elements must be INT or FLOAT".to_string(), + Position::NONE, + ) + .into()); + }; + } + Ok(dv) + } + + /// Create a [`RhaiVector`] from a [`nalgebra::DVector`]. + #[cfg(feature = "nalgebra")] + #[must_use] + pub fn from_dvector(vec: &DVector) -> Self { + let mut data = Vec::with_capacity(vec.len()); + for i in 0..vec.len() { + data.push(Dynamic::from_float(vec[i])); + } + Self(data) + } +} diff --git a/src/patterns.rs b/src/patterns.rs index baef622..1d829e3 100644 --- a/src/patterns.rs +++ b/src/patterns.rs @@ -1,3 +1,4 @@ +use crate::matrix::{RhaiMatrix, RhaiVector}; use rhai::{Array, Dynamic, EvalAltResult, Position, FLOAT, INT}; /// Matrix compatibility conditions @@ -227,37 +228,31 @@ where } pub fn array_to_vec_int(arr: &mut Array) -> Vec { - arr.iter() - .map(|el| el.as_int().unwrap()) - .collect::>() + RhaiVector::from_array(arr.clone()) + .to_dvector() + .expect("Array elements must be numeric") + .iter() + .map(|v| *v as INT) + .collect() } pub fn array_to_vec_float(arr: &mut Array) -> Vec { - arr.into_iter() - .map(|el| el.as_float().unwrap()) - .collect::>() + RhaiVector::from_array(arr.clone()) + .to_dvector() + .expect("Array elements must be numeric") + .iter() + .copied() + .collect() } #[cfg(feature = "nalgebra")] pub fn omatrix_to_vec_dynamic( mat: nalgebralib::OMatrix, ) -> Vec { - let mut out = vec![]; - for idx in 0..mat.shape().0 { - let mut new_row = vec![]; - for jdx in 0..mat.shape().1 { - new_row.push(Dynamic::from_float(mat[(idx, jdx)])); - } - out.push(Dynamic::from_array(new_row)); - } - out + RhaiMatrix::from_dmatrix(&mat).to_array() } #[cfg(feature = "nalgebra")] pub fn ovector_to_vec_dynamic(mat: nalgebralib::OVector) -> Vec { - let mut out = vec![]; - for idx in 0..mat.shape().0 { - out.push(Dynamic::from_float(mat[idx])); - } - out + RhaiVector::from_dvector(&mat).to_array() } From 4a0b44997b167ba2c0f761228df0b61d4f1271a7 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sat, 6 Sep 2025 20:35:23 -0400 Subject: [PATCH 02/22] Add vector orientation helpers and tests --- README.md | 14 ++++++ src/matrices_and_arrays.rs | 2 +- src/matrix/mod.rs | 96 ++++++++++++++++++++++++++++++++++++++ src/trig.rs | 3 +- src/validate.rs | 15 +++--- tests/orientation.rs | 34 ++++++++++++++ 6 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 tests/orientation.rs diff --git a/README.md b/README.md index 45be01d..4c341bb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,20 @@ engine.register_global_module(SciPackage::new().as_shared_module()); let value = engine.eval::("argmin([43, 42, -500])").unwrap(); ``` +## Matrix helpers + +`rhai-sci` provides constructors for row and column vectors and utilities to reorient +`1×N` and `N×1` matrices: + +```rust +use rhai::{Array, Dynamic}; +use rhai_sci::matrix::RhaiMatrix; + +let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; +let row = RhaiMatrix::row_vector(data.clone()); +let column = row.as_column().unwrap(); +``` + # Features | Feature | Default | Description | diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 512ba53..aee11ef 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -337,7 +337,7 @@ pub mod matrix_functions { .count() as INT } - #[cfg(all(feature = "io"))] + #[cfg(feature = "io")] pub mod read_write { use polars::prelude::{CsvReadOptions, DataType, SerReader}; use rhai::{Array, Dynamic, EvalAltResult, ImmutableString, FLOAT}; diff --git a/src/matrix/mod.rs b/src/matrix/mod.rs index 75ee7e3..f8496fa 100644 --- a/src/matrix/mod.rs +++ b/src/matrix/mod.rs @@ -28,6 +28,102 @@ impl RhaiMatrix { Self(arr) } + /// Construct a [`RhaiMatrix`] representing a row vector (`1×N`). + /// + /// # Examples + /// ``` + /// use rhai::{Array, Dynamic}; + /// use rhai_sci::matrix::RhaiMatrix; + /// let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + /// let row = RhaiMatrix::row_vector(data.clone()); + /// assert!(row.as_row().is_some()); + /// ``` + #[must_use] + pub fn row_vector(data: Array) -> Self { + Self(vec![Dynamic::from_array(data)]) + } + + /// Construct a [`RhaiMatrix`] representing a column vector (`N×1`). + /// + /// # Examples + /// ``` + /// use rhai::{Array, Dynamic}; + /// use rhai_sci::matrix::RhaiMatrix; + /// let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + /// let column = RhaiMatrix::column_vector(data.clone()); + /// assert!(column.as_column().is_some()); + /// ``` + #[must_use] + pub fn column_vector(data: Array) -> Self { + let rows = data + .into_iter() + .map(|v| Dynamic::from_array(vec![v])) + .collect(); + Self(rows) + } + + /// Return the matrix as a row vector (`1×N`), reshaping a column vector if necessary. + /// + /// Returns `None` if the matrix is not `1×N` or `N×1`. + /// + /// # Examples + /// ``` + /// use rhai::{Array, Dynamic}; + /// use rhai_sci::matrix::RhaiMatrix; + /// let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + /// let column = RhaiMatrix::column_vector(data.clone()); + /// let row = column.as_row().unwrap(); + /// assert!(row.as_row().is_some()); + /// ``` + #[must_use] + pub fn as_row(&self) -> Option { + let mut arr = self.0.clone(); + let shape = crate::matrix_functions::matrix_size_by_reference(&mut arr.clone()); + if shape.len() == 2 { + if shape[0].as_int().unwrap() == 1_i64 { + Some(self.clone()) + } else if shape[1].as_int().unwrap() == 1_i64 { + let flat = crate::matrix_functions::flatten(&mut arr); + Some(Self::row_vector(flat)) + } else { + None + } + } else { + None + } + } + + /// Return the matrix as a column vector (`N×1`), reshaping a row vector if necessary. + /// + /// Returns `None` if the matrix is not `1×N` or `N×1`. + /// + /// # Examples + /// ``` + /// use rhai::{Array, Dynamic}; + /// use rhai_sci::matrix::RhaiMatrix; + /// let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + /// let row = RhaiMatrix::row_vector(data.clone()); + /// let column = row.as_column().unwrap(); + /// assert!(column.as_column().is_some()); + /// ``` + #[must_use] + pub fn as_column(&self) -> Option { + let mut arr = self.0.clone(); + let shape = crate::matrix_functions::matrix_size_by_reference(&mut arr.clone()); + if shape.len() == 2 { + if shape[1].as_int().unwrap() == 1_i64 { + Some(self.clone()) + } else if shape[0].as_int().unwrap() == 1_i64 { + let flat = crate::matrix_functions::flatten(&mut arr); + Some(Self::column_vector(flat)) + } else { + None + } + } else { + None + } + } + /// Convert the matrix back into a [`rhai::Array`]. #[must_use] pub fn to_array(self) -> Array { diff --git a/src/trig.rs b/src/trig.rs index 3ca82ac..e9862c7 100644 --- a/src/trig.rs +++ b/src/trig.rs @@ -2,8 +2,7 @@ use rhai::plugin::*; #[export_module] pub mod trig_functions { - use crate::if_int_convert_to_float_and_do; - use rhai::{Array, Dynamic, EvalAltResult, Position, FLOAT, INT}; + use rhai::{Array, Dynamic, FLOAT}; /// Converts the argument from degrees to radians /// ```typescript diff --git a/src/validate.rs b/src/validate.rs index 5b598ec..d2759db 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -2,6 +2,7 @@ use rhai::plugin::*; #[export_module] pub mod validation_functions { + use crate::matrix::RhaiMatrix; use rhai::{Array, Dynamic}; /// Tests whether the input in a simple list array @@ -113,9 +114,10 @@ pub mod validation_functions { /// ``` #[rhai_fn(name = "is_row_vector", pure)] pub fn is_row_vector(arr: &mut Array) -> bool { - let s = crate::matrix_functions::matrix_size_by_reference(arr); - if s.len() == 2 && s[0].as_int().unwrap() == 1 { - true + let matrix = RhaiMatrix::from_array(arr.clone()); + if matrix.as_row().is_some() { + let s = crate::matrix_functions::matrix_size_by_reference(arr); + s.len() == 2 && s[0].as_int().unwrap() == 1_i64 } else { false } @@ -132,9 +134,10 @@ pub mod validation_functions { /// ``` #[rhai_fn(name = "is_column_vector", pure)] pub fn is_column_vector(arr: &mut Array) -> bool { - let s = crate::matrix_functions::matrix_size_by_reference(arr); - if s.len() == 2 && s[1].as_int().unwrap() == 1 { - true + let matrix = RhaiMatrix::from_array(arr.clone()); + if matrix.as_column().is_some() { + let s = crate::matrix_functions::matrix_size_by_reference(arr); + s.len() == 2 && s[1].as_int().unwrap() == 1_i64 } else { false } diff --git a/tests/orientation.rs b/tests/orientation.rs new file mode 100644 index 0000000..abbde0a --- /dev/null +++ b/tests/orientation.rs @@ -0,0 +1,34 @@ +use rhai::{Array, Dynamic}; +use rhai_sci::matrix::RhaiMatrix; +use rhai_sci::validation_functions::{is_column_vector, is_row_vector}; + +#[test] +fn row_column_constructors_and_orientation() { + let data: Array = vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ]; + let row = RhaiMatrix::row_vector(data.clone()); + let column = RhaiMatrix::column_vector(data.clone()); + let mut row_to_col = row.as_column().unwrap().to_array(); + assert!(is_column_vector(&mut row_to_col)); + let mut col_to_row = column.as_row().unwrap().to_array(); + assert!(is_row_vector(&mut col_to_row)); +} + +#[test] +fn validate_orientation_helpers() { + let mut row: Array = vec![Dynamic::from_array(vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + ])]; + let column: Array = vec![ + Dynamic::from_array(vec![Dynamic::from_int(1)]), + Dynamic::from_array(vec![Dynamic::from_int(2)]), + ]; + assert!(is_row_vector(&mut row.clone())); + assert!(!is_row_vector(&mut column.clone())); + assert!(is_column_vector(&mut column.clone())); + assert!(!is_column_vector(&mut row)); +} From 7f09b2f207aa48aa006bb9e971967c040a1137ab Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 08:54:27 -0400 Subject: [PATCH 03/22] Adjust matrix operation tests for dynamic comparisons --- README.md | 2 +- src/matrices_and_arrays.rs | 125 +++++++++++-------------------------- src/matrix/mod.rs | 67 ++++++++++++++++++++ src/statistics.rs | 7 ++- tests/matrix_ops.rs | 58 +++++++++++++++++ 5 files changed, 167 insertions(+), 92 deletions(-) create mode 100644 tests/matrix_ops.rs diff --git a/README.md b/README.md index 4c341bb..4a0ca2d 100644 --- a/README.md +++ b/README.md @@ -63,5 +63,5 @@ let column = row.as_column().unwrap(); |------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `metadata` | Disabled | Enables exporting function metadata and is ___necessary for running doc-tests on Rhai examples___. | | `io` | Enabled | Enables the [`read_matrix`](#read_matrixfile_path-string---array) function but pulls in several additional dependencies (`polars`, `url`, `temp-file`, `csv-sniffer`, `minreq`). | -| `nalgebra` | Enabled | Enables several functions ([`regress`](#regressx-array-y-array---map), [`inv`](#invmatrix-array---array), [`mtimes`](#mtimesmatrix1-array-matrix2-array---array), [`horzcat`](#horzcatmatrix1-array-matrix2-array---array), [`vertcat`](#vertcatmatrix1-array-matrix2-array---array), [`repmat`](#repmatmatrix-array-nx-i64-ny-i64---array), [`svd`](#svdmatrix-array---map), [`hessenberg`](#hessenbergmatrix-array---map), and [`qr`](#qrmatrix-array---map)) but brings in the `nalgebra` and `linregress` crates. | +| `nalgebra` | Enabled | Enables several functions ([`regress`](#regressx-array-y-array---map), [`inv`](#invmatrix-array---array), [`mtimes`](#mtimesmatrix1-array-matrix2-array---array), [`horzcat`](#horzcatmatrix1-rhaimatrix-matrix2-rhaimatrix---rhaimatrix), [`vertcat`](#vertcatmatrix1-rhaimatrix-matrix2-rhaimatrix---rhaimatrix), [`repmat`](#repmatmatrix-rhaimatrix-nx-i64-ny-i64---rhaimatrix), [`svd`](#svdmatrix-array---map), [`hessenberg`](#hessenbergmatrix-array---map), and [`qr`](#qrmatrix-array---map)) but brings in the `nalgebra` and `linregress` crates. | | `rand` | Enabled | Enables the [`rand`](#rand) function for generating random FLOAT values and random matrices, but brings in the `rand` crate. | diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index aee11ef..c78656e 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -9,7 +9,7 @@ pub mod matrix_functions { if_matrix_convert_to_vec_array_and_do, }; #[cfg(feature = "nalgebra")] - use crate::{if_matrices_and_compatible_convert_to_vec_array_and_do, if_matrix_do, FOIL}; + use crate::{if_matrices_and_compatible_convert_to_vec_array_and_do, FOIL}; #[cfg(feature = "nalgebra")] use nalgebralib::DMatrix; use rhai::{Array, Dynamic, EvalAltResult, Map, Position, FLOAT, INT}; @@ -249,20 +249,13 @@ pub mod matrix_functions { /// let matrix = transpose(eye(3)); /// assert_eq(matrix, eye(3)); /// ``` - #[rhai_fn(name = "transpose", pure, return_raw)] - pub fn transpose(matrix: &mut Array) -> Result> { - if_matrix_convert_to_vec_array_and_do(matrix, |matrix_as_vec| { - // Turn into Array - let mut out = vec![]; - for idx in 0..matrix_as_vec[0].len() { - let mut new_row = vec![]; - for jdx in 0..matrix_as_vec.len() { - new_row.push(matrix_as_vec[jdx][idx].clone()); - } - out.push(Dynamic::from_array(new_row)); - } - Ok(out) - }) + #[rhai_fn(name = "transpose", return_raw)] + pub fn transpose(matrix: RhaiMatrix) -> Result> { + let oriented = matrix + .as_row() + .or_else(|| matrix.as_column()) + .unwrap_or(matrix); + oriented.transpose() } /// Returns an array indicating the size of the matrix along each dimension, passed by reference. @@ -908,36 +901,13 @@ pub mod matrix_functions { /// ``` #[cfg(feature = "nalgebra")] #[rhai_fn(name = "horzcat", return_raw)] - pub fn horzcat(matrix1: Array, matrix2: Array) -> Result> { - if_matrices_and_compatible_convert_to_vec_array_and_do( - FOIL::First, - &mut matrix1.clone(), - &mut matrix2.clone(), - |matrix_as_vec1, matrix_as_vec2| { - let arr1: Array = matrix_as_vec1 - .into_iter() - .map(Dynamic::from_array) - .collect(); - let arr2: Array = matrix_as_vec2 - .into_iter() - .map(Dynamic::from_array) - .collect(); - let dm1 = RhaiMatrix::from_array(arr1).to_dmatrix()?; - let dm2 = RhaiMatrix::from_array(arr2).to_dmatrix()?; - - let w0 = dm1.shape().1; - let w = dm1.shape().1 + dm2.shape().1; - let h = dm1.shape().0; - let mat = DMatrix::from_fn(h, w, |i, j| { - if j >= w0 { - dm2[(i, j - w0)] - } else { - dm1[(i, j)] - } - }); - Ok(RhaiMatrix::from_dmatrix(&mat).to_array()) - }, - ) + pub fn horzcat( + matrix1: RhaiMatrix, + matrix2: RhaiMatrix, + ) -> Result> { + let left = matrix1.as_row().unwrap_or(matrix1); + let right = matrix2.as_row().unwrap_or(matrix2); + left.concat_h(&right) } /// Concatenates two array vertically. @@ -949,36 +919,13 @@ pub mod matrix_functions { /// ``` #[cfg(feature = "nalgebra")] #[rhai_fn(name = "vertcat", return_raw)] - pub fn vertcat(matrix1: Array, matrix2: Array) -> Result> { - if_matrices_and_compatible_convert_to_vec_array_and_do( - FOIL::Last, - &mut matrix1.clone(), - &mut matrix2.clone(), - |matrix_as_vec1, matrix_as_vec2| { - let arr1: Array = matrix_as_vec1 - .into_iter() - .map(Dynamic::from_array) - .collect(); - let arr2: Array = matrix_as_vec2 - .into_iter() - .map(Dynamic::from_array) - .collect(); - let dm1 = RhaiMatrix::from_array(arr1).to_dmatrix()?; - let dm2 = RhaiMatrix::from_array(arr2).to_dmatrix()?; - - let h0 = dm1.shape().0; - let w = dm1.shape().1; - let h = dm1.shape().0 + dm2.shape().0; - let mat = DMatrix::from_fn(h, w, |i, j| { - if i >= h0 { - dm2[(i - h0, j)] - } else { - dm1[(i, j)] - } - }); - Ok(RhaiMatrix::from_dmatrix(&mat).to_array()) - }, - ) + pub fn vertcat( + matrix1: RhaiMatrix, + matrix2: RhaiMatrix, + ) -> Result> { + let top = matrix1.as_column().unwrap_or(matrix1); + let bottom = matrix2.as_column().unwrap_or(matrix2); + top.concat_v(&bottom) } /// This function can be used in two distinct ways. @@ -1049,18 +996,16 @@ pub mod matrix_functions { /// ``` #[cfg(feature = "nalgebra")] #[rhai_fn(name = "repmat", return_raw)] - pub fn repmat(matrix: &mut Array, nx: INT, ny: INT) -> Result> { - if_matrix_do(matrix, |matrix| { - let mut row_matrix = matrix.clone(); - for _ in 1..ny { - row_matrix = horzcat(row_matrix, matrix.clone())?; - } - let mut new_matrix = row_matrix.clone(); - for _ in 1..nx { - new_matrix = vertcat(new_matrix, row_matrix.clone())?; - } - Ok(new_matrix) - }) + pub fn repmat(matrix: RhaiMatrix, nx: INT, ny: INT) -> Result> { + let mut row_matrix = matrix.clone(); + for _ in 1..ny { + row_matrix = horzcat(row_matrix, matrix.clone())?; + } + let mut new_matrix = row_matrix.clone(); + for _ in 1..nx { + new_matrix = vertcat(new_matrix, row_matrix.clone())?; + } + Ok(new_matrix) } /// Returns an object map containing 2-D grid coordinates based on the uni-axial coordinates @@ -1081,7 +1026,7 @@ pub mod matrix_functions { let nx = x.len(); let ny = y.len(); let x_dyn: Array = vec![Dynamic::from_array(x.to_vec()); nx]; - let mut y_dyn: Array = vec![Dynamic::from_array(y.to_vec()); ny]; + let y_dyn: Array = vec![Dynamic::from_array(y.to_vec()); ny]; let mut result = BTreeMap::new(); let mut xid = smartstring::SmartString::new(); @@ -1089,7 +1034,9 @@ pub mod matrix_functions { let mut yid = smartstring::SmartString::new(); yid.push_str("y"); result.insert(xid, Dynamic::from_array(x_dyn)); - result.insert(yid, Dynamic::from_array(transpose(&mut y_dyn).unwrap())); + let y_matrix = RhaiMatrix::from_array(y_dyn); + let y_t = transpose(y_matrix)?.to_array(); + result.insert(yid, Dynamic::from_array(y_t)); Ok(result) }) }) diff --git a/src/matrix/mod.rs b/src/matrix/mod.rs index f8496fa..8145899 100644 --- a/src/matrix/mod.rs +++ b/src/matrix/mod.rs @@ -197,6 +197,73 @@ impl RhaiMatrix { } Self(rows) } + + /// Transpose the matrix. + /// + /// # Errors + /// Returns an error if the matrix contains non-numeric values or rows of + /// unequal length. + #[cfg(feature = "nalgebra")] + pub fn transpose(&self) -> Result> { + let dm = self.to_dmatrix()?; + Ok(Self::from_dmatrix(&dm.transpose())) + } + + /// Horizontally concatenate two matrices. + /// + /// # Errors + /// Returns an error if the matrices have differing row counts or contain + /// non-numeric values. + #[cfg(feature = "nalgebra")] + pub fn concat_h(&self, other: &Self) -> Result> { + let left = self.to_dmatrix()?; + let right = other.to_dmatrix()?; + if left.nrows() != right.nrows() { + return Err(EvalAltResult::ErrorArithmetic( + "Matrices must have the same number of rows".to_string(), + Position::NONE, + ) + .into()); + } + let cols = left.ncols() + right.ncols(); + let rows = left.nrows(); + let mat = DMatrix::from_fn(rows, cols, |i, j| { + if j < left.ncols() { + left[(i, j)] + } else { + right[(i, j - left.ncols())] + } + }); + Ok(Self::from_dmatrix(&mat)) + } + + /// Vertically concatenate two matrices. + /// + /// # Errors + /// Returns an error if the matrices have differing column counts or contain + /// non-numeric values. + #[cfg(feature = "nalgebra")] + pub fn concat_v(&self, other: &Self) -> Result> { + let top = self.to_dmatrix()?; + let bottom = other.to_dmatrix()?; + if top.ncols() != bottom.ncols() { + return Err(EvalAltResult::ErrorArithmetic( + "Matrices must have the same number of columns".to_string(), + Position::NONE, + ) + .into()); + } + let rows = top.nrows() + bottom.nrows(); + let cols = top.ncols(); + let mat = DMatrix::from_fn(rows, cols, |i, j| { + if i < top.nrows() { + top[(i, j)] + } else { + bottom[(i - top.nrows(), j)] + } + }); + Ok(Self::from_dmatrix(&mat)) + } } /// Wrapper around [`rhai::Array`] representing a vector. diff --git a/src/statistics.rs b/src/statistics.rs index 4228785..d7c3f0c 100644 --- a/src/statistics.rs +++ b/src/statistics.rs @@ -7,6 +7,8 @@ pub mod stats { if_list_do_int_or_do_float, }; #[cfg(feature = "nalgebra")] + use crate::matrix::RhaiMatrix; + #[cfg(feature = "nalgebra")] use rhai::Map; use rhai::{Array, Dynamic, EvalAltResult, Position, FLOAT, INT}; @@ -550,10 +552,11 @@ pub mod stats { #[rhai_fn(name = "regress", return_raw, pure)] pub fn regress(x: &mut Array, y: Array) -> Result> { use linregress::{FormulaRegressionBuilder, RegressionDataBuilder}; - let x_transposed = crate::matrix_functions::transpose(x)?; + let x_transposed = crate::matrix_functions::transpose(RhaiMatrix::from_array(x.clone()))?; + let x_arr = x_transposed.to_array(); let mut data: Vec<(String, Vec)> = vec![]; let mut vars = vec![]; - for (iter, column) in x_transposed.iter().enumerate() { + for (iter, column) in x_arr.iter().enumerate() { let var_name = format!("x_{iter}"); vars.push(var_name.clone()); data.push(( diff --git a/tests/matrix_ops.rs b/tests/matrix_ops.rs new file mode 100644 index 0000000..2a8bc7a --- /dev/null +++ b/tests/matrix_ops.rs @@ -0,0 +1,58 @@ +use rhai::{Array, Dynamic, FLOAT, INT}; +use rhai_sci::matrix::RhaiMatrix; +use rhai_sci::matrix_functions::{horzcat, matrix_size_by_reference, repmat, transpose, vertcat}; +use rhai_sci::validation_functions::{is_column_vector, is_row_vector}; + +#[test] +fn transpose_orients_row_vector() { + let data: Array = vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ]; + let row = RhaiMatrix::row_vector(data); + let mut result = transpose(row).unwrap().to_array(); + assert!(is_column_vector(&mut result)); +} + +#[test] +fn horzcat_concatenates_rows() { + let a: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + let b: Array = vec![Dynamic::from_int(3), Dynamic::from_int(4)]; + let m1 = RhaiMatrix::row_vector(a); + let m2 = RhaiMatrix::row_vector(b); + let mut result = horzcat(m1, m2).unwrap().to_array(); + assert!(is_row_vector(&mut result)); + let row = result[0].clone().into_array().unwrap(); + let values: Vec = row.into_iter().map(|d| d.as_float().unwrap()).collect(); + assert_eq!(values, vec![1.0, 2.0, 3.0, 4.0]); +} + +#[test] +fn vertcat_concatenates_columns() { + let a: Array = vec![ + Dynamic::from_array(vec![Dynamic::from_int(1)]), + Dynamic::from_array(vec![Dynamic::from_int(2)]), + ]; + let b: Array = vec![ + Dynamic::from_array(vec![Dynamic::from_int(3)]), + Dynamic::from_array(vec![Dynamic::from_int(4)]), + ]; + let m1 = RhaiMatrix::from_array(a); + let m2 = RhaiMatrix::from_array(b); + let mut result = vertcat(m1, m2).unwrap().to_array(); + assert!(is_column_vector(&mut result)); +} + +#[test] +fn repmat_replicates_matrix() { + let data: Array = vec![ + Dynamic::from_array(vec![Dynamic::from_int(1), Dynamic::from_int(2)]), + Dynamic::from_array(vec![Dynamic::from_int(3), Dynamic::from_int(4)]), + ]; + let m = RhaiMatrix::from_array(data); + let mut result = repmat(m, 2, 2).unwrap().to_array(); + let shape = matrix_size_by_reference(&mut result); + let dims: Vec = shape.into_iter().map(|d| d.as_int().unwrap()).collect(); + assert_eq!(dims, vec![4, 4]); +} From 69a7d94233c429f2cac1483e36493635efe56200 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 18:04:58 -0400 Subject: [PATCH 04/22] Expose array wrappers for matrix ops --- src/matrices_and_arrays.rs | 51 +++++++++++++++++++++++++------- tests/fixtures/sample_matrix.csv | 2 ++ 2 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/sample_matrix.csv diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index c78656e..708b25f 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -240,10 +240,10 @@ pub mod matrix_functions { /// ```typescript /// let row = [[1, 2, 3, 4]]; /// let column = transpose(row); - /// assert_eq(column, [[1], - /// [2], - /// [3], - /// [4]]); + /// assert_eq(column, [[1.0], + /// [2.0], + /// [3.0], + /// [4.0]]); /// ``` /// ```typescript /// let matrix = transpose(eye(3)); @@ -258,6 +258,12 @@ pub mod matrix_functions { oriented.transpose() } + /// Transpose an array by first converting it to a [`RhaiMatrix`]. + #[rhai_fn(name = "transpose", return_raw)] + pub fn transpose_from_array(matrix: Array) -> Result> { + transpose(RhaiMatrix::from_array(matrix)).map(RhaiMatrix::to_array) + } + /// Returns an array indicating the size of the matrix along each dimension, passed by reference. /// ```typescript /// let matrix = ones(3, 5); @@ -335,11 +341,10 @@ pub mod matrix_functions { use polars::prelude::{CsvReadOptions, DataType, SerReader}; use rhai::{Array, Dynamic, EvalAltResult, ImmutableString, FLOAT}; - /// Reads a numeric csv file from a url + /// Reads a numeric CSV file from the filesystem /// ```typescript - /// let url = "https://raw.githubusercontent.com/plotly/datasets/master/diabetes.csv"; - /// let x = read_matrix(url); - /// assert_eq(size(x), [768, 9]); + /// let x = read_matrix("tests/fixtures/sample_matrix.csv"); + /// assert_eq(x, [[1.0, 2.0], [3.0, 4.0]]); /// ``` #[rhai_fn(name = "read_matrix", return_raw)] pub fn read_matrix(file_path: ImmutableString) -> Result> { @@ -910,6 +915,16 @@ pub mod matrix_functions { left.concat_h(&right) } + #[cfg(feature = "nalgebra")] + #[rhai_fn(name = "horzcat", return_raw)] + pub fn horzcat_from_array(matrix1: Array, matrix2: Array) -> Result> { + horzcat( + RhaiMatrix::from_array(matrix1), + RhaiMatrix::from_array(matrix2), + ) + .map(RhaiMatrix::to_array) + } + /// Concatenates two array vertically. /// ```typescript /// let arr1 = eye(3); @@ -928,6 +943,16 @@ pub mod matrix_functions { top.concat_v(&bottom) } + #[cfg(feature = "nalgebra")] + #[rhai_fn(name = "vertcat", return_raw)] + pub fn vertcat_from_array(matrix1: Array, matrix2: Array) -> Result> { + vertcat( + RhaiMatrix::from_array(matrix1), + RhaiMatrix::from_array(matrix2), + ) + .map(RhaiMatrix::to_array) + } + /// This function can be used in two distinct ways. /// 1. If the argument is an 2-D array, `diag` returns an array containing the diagonal of the array. /// 2. If the argument is a 1-D array, `diag` returns a matrix containing the argument along the @@ -1008,6 +1033,12 @@ pub mod matrix_functions { Ok(new_matrix) } + #[cfg(feature = "nalgebra")] + #[rhai_fn(name = "repmat", return_raw)] + pub fn repmat_from_array(matrix: Array, nx: INT, ny: INT) -> Result> { + repmat(RhaiMatrix::from_array(matrix), nx, ny).map(RhaiMatrix::to_array) + } + /// Returns an object map containing 2-D grid coordinates based on the uni-axial coordinates /// contained in arguments x and y. /// ```typescript @@ -1016,8 +1047,8 @@ pub mod matrix_functions { /// let g = meshgrid(x, y); /// assert_eq(g, #{"x": [[1, 2], /// [1, 2]], - /// "y": [[3, 3], - /// [4, 4]]}); + /// "y": [[3.0, 3.0], + /// [4.0, 4.0]]}); /// ``` #[rhai_fn(name = "meshgrid", return_raw)] pub fn meshgrid(x: Array, y: Array) -> Result> { diff --git a/tests/fixtures/sample_matrix.csv b/tests/fixtures/sample_matrix.csv new file mode 100644 index 0000000..ebb6763 --- /dev/null +++ b/tests/fixtures/sample_matrix.csv @@ -0,0 +1,2 @@ +1,2 +3,4 From c36476d9989e7155358cbae36d70ce646ed43034 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 19:47:46 -0400 Subject: [PATCH 05/22] Use matrix methods for repmat --- src/matrices_and_arrays.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 708b25f..b38fa21 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -1022,15 +1022,17 @@ pub mod matrix_functions { #[cfg(feature = "nalgebra")] #[rhai_fn(name = "repmat", return_raw)] pub fn repmat(matrix: RhaiMatrix, nx: INT, ny: INT) -> Result> { - let mut row_matrix = matrix.clone(); - for _ in 1..ny { - row_matrix = horzcat(row_matrix, matrix.clone())?; - } - let mut new_matrix = row_matrix.clone(); - for _ in 1..nx { - new_matrix = vertcat(new_matrix, row_matrix.clone())?; - } - Ok(new_matrix) + let oriented = matrix + .as_column() + .or_else(|| matrix.as_row()) + .unwrap_or(matrix); + let dm = oriented.to_dmatrix()?; + let nx = if nx < 1 { 1 } else { nx as usize }; + let ny = if ny < 1 { 1 } else { ny as usize }; + let mat = DMatrix::from_fn(dm.nrows() * nx, dm.ncols() * ny, |i, j| { + dm[(i % dm.nrows(), j % dm.ncols())] + }); + Ok(RhaiMatrix::from_dmatrix(&mat)) } #[cfg(feature = "nalgebra")] From 71fcd4129405004e6907688862780e39d85b70bd Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 20:04:53 -0400 Subject: [PATCH 06/22] test: add matrix vector orientation tests --- tests/matrix_vectors.rs | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/matrix_vectors.rs diff --git a/tests/matrix_vectors.rs b/tests/matrix_vectors.rs new file mode 100644 index 0000000..15748f8 --- /dev/null +++ b/tests/matrix_vectors.rs @@ -0,0 +1,79 @@ +use rhai::{Array, Dynamic}; +use rhai_sci::matrix::RhaiMatrix; +use rhai_sci::matrix_functions::{horzcat, matrix_size_by_reference, transpose, vertcat}; +use rhai_sci::validation_functions::{is_column_vector, is_row_vector}; + +#[test] +fn constructors_create_properly_oriented_vectors() { + // Row vector constructor produces 1xN matrix + let row_data: Array = vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ]; + let mut row = RhaiMatrix::row_vector(row_data).to_array(); + assert!(is_row_vector(&mut row)); + + // Column vector constructor produces Nx1 matrix + let column_data: Array = vec![ + Dynamic::from_int(4), + Dynamic::from_int(5), + Dynamic::from_int(6), + ]; + let mut column = RhaiMatrix::column_vector(column_data).to_array(); + assert!(is_column_vector(&mut column)); +} + +#[test] +fn as_column_converts_row_to_column() { + let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + let row = RhaiMatrix::row_vector(data); + let mut column = row.as_column().unwrap().to_array(); + assert!(is_column_vector(&mut column)); +} + +#[test] +fn as_row_converts_column_to_row() { + let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + let column = RhaiMatrix::column_vector(data); + let mut row = column.as_row().unwrap().to_array(); + assert!(is_row_vector(&mut row)); +} + +#[test] +fn transpose_flips_vector_orientation() { + let data: Array = vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ]; + let row = RhaiMatrix::row_vector(data); + let mut transposed = transpose(row).unwrap().to_array(); + assert!(is_column_vector(&mut transposed)); +} + +#[test] +fn horzcat_produces_row_vector() { + let left: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + let right: Array = vec![Dynamic::from_int(3), Dynamic::from_int(4)]; + let m1 = RhaiMatrix::row_vector(left); + let m2 = RhaiMatrix::row_vector(right); + let mut result = horzcat(m1, m2).unwrap().to_array(); + assert!(is_row_vector(&mut result)); + let dims = matrix_size_by_reference(&mut result); + assert_eq!(dims[0].as_int().unwrap(), 1); + assert_eq!(dims[1].as_int().unwrap(), 4); +} + +#[test] +fn vertcat_produces_column_vector() { + let top: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; + let bottom: Array = vec![Dynamic::from_int(3), Dynamic::from_int(4)]; + let m1 = RhaiMatrix::column_vector(top); + let m2 = RhaiMatrix::column_vector(bottom); + let mut result = vertcat(m1, m2).unwrap().to_array(); + assert!(is_column_vector(&mut result)); + let dims = matrix_size_by_reference(&mut result); + assert_eq!(dims[0].as_int().unwrap(), 4); + assert_eq!(dims[1].as_int().unwrap(), 1); +} From ae3959c80c78cb2333fa3b745dfc0384bdc3a6fa Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 20:22:40 -0400 Subject: [PATCH 07/22] docs: clarify matrix orientation --- README.md | 17 ++++++++++++----- src/matrices_and_arrays.rs | 16 ++++++++-------- src/matrix/mod.rs | 30 +++++++++++++++++++++++++----- src/validate.rs | 20 ++++++++++---------- 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 4a0ca2d..e769163 100644 --- a/README.md +++ b/README.md @@ -43,20 +43,27 @@ engine.register_global_module(SciPackage::new().as_shared_module()); let value = engine.eval::("argmin([43, 42, -500])").unwrap(); ``` -## Matrix helpers +## Matrix & Vector Conventions -`rhai-sci` provides constructors for row and column vectors and utilities to reorient -`1×N` and `N×1` matrices: +Matrices use the conventional `n×m` shape where `n` is the number of rows and `m` +is the number of columns. Row vectors have shape `1×n` and column vectors use +`n×1`. + +`rhai-sci` provides helpers for constructing and reorienting these shapes: ```rust use rhai::{Array, Dynamic}; use rhai_sci::matrix::RhaiMatrix; let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; -let row = RhaiMatrix::row_vector(data.clone()); -let column = row.as_column().unwrap(); +let row = RhaiMatrix::row_vector(data.clone()); // 1×2 +let column = row.as_column().unwrap(); // 2×1 ``` +The `row_vector` and `column_vector` constructors create oriented vectors, +while `as_row` and `as_column` convert `1×n` and `n×1` matrices between the two +orientations. + # Features | Feature | Default | Description | diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index b38fa21..de637f8 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -899,10 +899,10 @@ pub mod matrix_functions { /// Concatenate two arrays horizontally. /// ```typescript - /// let arr1 = eye(3); - /// let arr2 = eye(3); - /// let combined = horzcat(arr1, arr2); - /// assert_eq(size(combined), [3, 6]); + /// let left = [[1, 2]]; + /// let right = [[3, 4]]; + /// let row = horzcat(left, right); + /// assert_eq(row, [[1.0, 2.0, 3.0, 4.0]]); /// ``` #[cfg(feature = "nalgebra")] #[rhai_fn(name = "horzcat", return_raw)] @@ -927,10 +927,10 @@ pub mod matrix_functions { /// Concatenates two array vertically. /// ```typescript - /// let arr1 = eye(3); - /// let arr2 = eye(3); - /// let combined = vertcat(arr1, arr2); - /// assert_eq(size(combined), [6, 3]); + /// let top = [[1], [2]]; + /// let bottom = [[3], [4]]; + /// let column = vertcat(top, bottom); + /// assert_eq(column, [[1.0], [2.0], [3.0], [4.0]]); /// ``` #[cfg(feature = "nalgebra")] #[rhai_fn(name = "vertcat", return_raw)] diff --git a/src/matrix/mod.rs b/src/matrix/mod.rs index 8145899..d5b0ea5 100644 --- a/src/matrix/mod.rs +++ b/src/matrix/mod.rs @@ -5,7 +5,7 @@ use rhai::{Array, Dynamic, EvalAltResult, Position, FLOAT}; /// Wrapper around [`rhai::Array`] representing a matrix. /// /// This type provides conversions between Rhai arrays and -/// [`nalgebra::DMatrix`]. +/// `nalgebra::DMatrix`. /// /// # Examples /// ``` @@ -130,7 +130,7 @@ impl RhaiMatrix { self.0 } - /// Convert the matrix into a [`nalgebra::DMatrix`]. + /// Convert the matrix into a `nalgebra::DMatrix`. /// /// # Errors /// Returns an error if any element is non-numeric or rows have differing lengths. @@ -183,7 +183,7 @@ impl RhaiMatrix { Ok(dm) } - /// Create a [`RhaiMatrix`] from a [`nalgebra::DMatrix`]. + /// Create a [`RhaiMatrix`] from a `nalgebra::DMatrix`. #[cfg(feature = "nalgebra")] #[must_use] pub fn from_dmatrix(mat: &DMatrix) -> Self { @@ -211,6 +211,16 @@ impl RhaiMatrix { /// Horizontally concatenate two matrices. /// + /// # Examples + /// ``` + /// use rhai::{Array, Dynamic}; + /// use rhai_sci::{matrix::RhaiMatrix, validation_functions::is_row_vector}; + /// let left = RhaiMatrix::row_vector(vec![Dynamic::from_int(1), Dynamic::from_int(2)]); + /// let right = RhaiMatrix::row_vector(vec![Dynamic::from_int(3), Dynamic::from_int(4)]); + /// let combined = left.concat_h(&right).unwrap(); + /// let mut arr = combined.to_array(); + /// assert!(is_row_vector(&mut arr)); + /// ``` /// # Errors /// Returns an error if the matrices have differing row counts or contain /// non-numeric values. @@ -239,6 +249,16 @@ impl RhaiMatrix { /// Vertically concatenate two matrices. /// + /// # Examples + /// ``` + /// use rhai::{Array, Dynamic}; + /// use rhai_sci::{matrix::RhaiMatrix, validation_functions::is_column_vector}; + /// let top = RhaiMatrix::column_vector(vec![Dynamic::from_int(1), Dynamic::from_int(2)]); + /// let bottom = RhaiMatrix::column_vector(vec![Dynamic::from_int(3), Dynamic::from_int(4)]); + /// let combined = top.concat_v(&bottom).unwrap(); + /// let mut arr = combined.to_array(); + /// assert!(is_column_vector(&mut arr)); + /// ``` /// # Errors /// Returns an error if the matrices have differing column counts or contain /// non-numeric values. @@ -292,7 +312,7 @@ impl RhaiVector { self.0 } - /// Convert the vector into a [`nalgebra::DVector`]. + /// Convert the vector into a `nalgebra::DVector`. /// /// # Errors /// Returns an error if any element is non-numeric. @@ -319,7 +339,7 @@ impl RhaiVector { Ok(dv) } - /// Create a [`RhaiVector`] from a [`nalgebra::DVector`]. + /// Create a [`RhaiVector`] from a `nalgebra::DVector`. #[cfg(feature = "nalgebra")] #[must_use] pub fn from_dvector(vec: &DVector) -> Self { diff --git a/src/validate.rs b/src/validate.rs index d2759db..b47dbd1 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -103,14 +103,14 @@ pub mod validation_functions { } } - /// Tests whether the input is a row vector + /// Tests whether the input is a row vector. /// ```typescript - /// let x = ones([1, 5]); - /// assert_eq(is_row_vector(x), true) + /// let row = [[1, 2, 3]]; + /// assert_eq(is_row_vector(row), true); /// ``` /// ```typescript - /// let x = ones([5, 5]); - /// assert_eq(is_row_vector(x), false) + /// let column = [[1], [2], [3]]; + /// assert_eq(is_row_vector(column), false); /// ``` #[rhai_fn(name = "is_row_vector", pure)] pub fn is_row_vector(arr: &mut Array) -> bool { @@ -123,14 +123,14 @@ pub mod validation_functions { } } - /// Tests whether the input is a column vector + /// Tests whether the input is a column vector. /// ```typescript - /// let x = ones([5, 1]); - /// assert_eq(is_column_vector(x), true) + /// let column = [[1], [2], [3]]; + /// assert_eq(is_column_vector(column), true); /// ``` /// ```typescript - /// let x = ones([5, 5]); - /// assert_eq(is_column_vector(x), false) + /// let row = [[1, 2, 3]]; + /// assert_eq(is_column_vector(row), false); /// ``` #[rhai_fn(name = "is_column_vector", pure)] pub fn is_column_vector(arr: &mut Array) -> bool { From 5b764de4437652e5fbe25f1d543ecbfa01559c35 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 20:33:04 -0400 Subject: [PATCH 08/22] docs: standardize readme and ci --- .github/workflows/tests.yml | 29 ++++++++------ README.md | 75 +++++++++++++++++++------------------ src/statistics.rs | 4 +- tests/rhai-sci-tests.rs | 2 +- 4 files changed, 59 insertions(+), 51 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7282ed4..4f01e1c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,22 +1,29 @@ -name: tests +name: ci on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] env: CARGO_TERM_COLOR: always jobs: - build: - + test: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Build - run: cargo build --verbose --all-features - - name: Run tests - run: cargo test --verbose --all-features \ No newline at end of file + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Format + run: cargo fmt --all -- --check + - name: Lint + run: cargo clippy --all-targets --no-default-features --features rand,nalgebra -- -D warnings -D clippy::pedantic + - name: Test + run: cargo test --no-default-features --features rand,nalgebra + diff --git a/README.md b/README.md index e769163..72b541c 100644 --- a/README.md +++ b/README.md @@ -2,73 +2,74 @@ [![Crates.io](https://img.shields.io/crates/v/rhai-sci.svg)](https://crates.io/crates/rhai-sci) [![docs.rs](https://img.shields.io/docsrs/rhai-sci/latest?logo=rust)](https://docs.rs/rhai-sci) -# About `rhai-sci` +# rhai-sci -This crate provides some basic scientific computing utilities for the [`Rhai`](https://rhai.rs/) scripting language, -inspired by languages like MATLAB, Octave, and R. For a complete API reference, -check [the docs](https://docs.rs/rhai-sci). +## What & Why -# Install +`rhai-sci` adds basic scientific computing utilities to the [Rhai](https://rhai.rs/) scripting language. It is inspired by tools such as MATLAB, Octave, and R. -To use the latest released version of `rhai-sci`, add this to your `Cargo.toml`: +## Quickstart + +Add the crate to your `Cargo.toml`: ```toml rhai-sci = "0.2.2" ``` -# Usage - -Using this crate is pretty simple! If you just want to evaluate a single line of [`Rhai`](https://rhai.rs/), then you -only need: +Evaluate a single line of Rhai code: ```rust use rhai::INT; use rhai_sci::eval; + let result = eval::("argmin([43, 42, -500])").unwrap(); ``` -If you need to use `rhai-sci` as part of a persistent [`Rhai`](https://rhai.rs/) scripting engine, then do this instead: +## Examples + +Integrate the package with a persistent Rhai engine: ```rust -use rhai::{Engine, packages::Package, INT}; +use rhai::{packages::Package, Engine, INT}; use rhai_sci::SciPackage; -// Create a new Rhai engine let mut engine = Engine::new(); - -// Add the rhai-sci package to the new engine engine.register_global_module(SciPackage::new().as_shared_module()); -// Now run your code let value = engine.eval::("argmin([43, 42, -500])").unwrap(); ``` -## Matrix & Vector Conventions +## Config -Matrices use the conventional `n×m` shape where `n` is the number of rows and `m` -is the number of columns. Row vectors have shape `1×n` and column vectors use -`n×1`. +### Features -`rhai-sci` provides helpers for constructing and reorienting these shapes: +- **metadata** *(disabled)*: export function metadata; required for running doc-tests on Rhai examples. +- **io** *(enabled)*: provides `read_matrix` but pulls in `polars`, `url`, `temp-file`, `csv-sniffer`, and `minreq`. +- **nalgebra** *(enabled)*: enables matrix functions such as `regress`, `inv`, `mtimes`, `horzcat`, `vertcat`, `repmat`, `svd`, `hessenberg`, and `qr` via the `nalgebra` and `linregress` crates. +- **rand** *(enabled)*: adds the `rand` function for generating random values and matrices using the `rand` crate. -```rust -use rhai::{Array, Dynamic}; -use rhai_sci::matrix::RhaiMatrix; +## CLI/API reference -let data: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; -let row = RhaiMatrix::row_vector(data.clone()); // 1×2 -let column = row.as_column().unwrap(); // 2×1 +The full API is documented on [docs.rs](https://docs.rs/rhai-sci). + +## Development + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --no-default-features --features rand,nalgebra -- -D warnings -D clippy::pedantic +cargo test --no-default-features --features rand,nalgebra ``` -The `row_vector` and `column_vector` constructors create oriented vectors, -while `as_row` and `as_column` convert `1×n` and `n×1` matrices between the two -orientations. +## Troubleshooting + +- Building with the `io` feature enabled pulls in heavy dependencies. Disable default features and enable only what you need if builds are slow. + +## License + +Licensed under either of + +- [MIT license](LICENSE-MIT.txt) +- [Apache License, Version 2.0](LICENSE-APACHE.txt) -# Features +at your option. -| Feature | Default | Description | -|------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `metadata` | Disabled | Enables exporting function metadata and is ___necessary for running doc-tests on Rhai examples___. | -| `io` | Enabled | Enables the [`read_matrix`](#read_matrixfile_path-string---array) function but pulls in several additional dependencies (`polars`, `url`, `temp-file`, `csv-sniffer`, `minreq`). | -| `nalgebra` | Enabled | Enables several functions ([`regress`](#regressx-array-y-array---map), [`inv`](#invmatrix-array---array), [`mtimes`](#mtimesmatrix1-array-matrix2-array---array), [`horzcat`](#horzcatmatrix1-rhaimatrix-matrix2-rhaimatrix---rhaimatrix), [`vertcat`](#vertcatmatrix1-rhaimatrix-matrix2-rhaimatrix---rhaimatrix), [`repmat`](#repmatmatrix-rhaimatrix-nx-i64-ny-i64---rhaimatrix), [`svd`](#svdmatrix-array---map), [`hessenberg`](#hessenbergmatrix-array---map), and [`qr`](#qrmatrix-array---map)) but brings in the `nalgebra` and `linregress` crates. | -| `rand` | Enabled | Enables the [`rand`](#rand) function for generating random FLOAT values and random matrices, but brings in the `rand` crate. | diff --git a/src/statistics.rs b/src/statistics.rs index d7c3f0c..860256b 100644 --- a/src/statistics.rs +++ b/src/statistics.rs @@ -2,13 +2,13 @@ use rhai::plugin::*; #[export_module] pub mod stats { + #[cfg(feature = "nalgebra")] + use crate::matrix::RhaiMatrix; use crate::{ array_to_vec_float, array_to_vec_int, if_list_convert_to_vec_float_and_do, if_list_do, if_list_do_int_or_do_float, }; #[cfg(feature = "nalgebra")] - use crate::matrix::RhaiMatrix; - #[cfg(feature = "nalgebra")] use rhai::Map; use rhai::{Array, Dynamic, EvalAltResult, Position, FLOAT, INT}; diff --git a/tests/rhai-sci-tests.rs b/tests/rhai-sci-tests.rs index f833da3..f5f4a0c 100644 --- a/tests/rhai-sci-tests.rs +++ b/tests/rhai-sci-tests.rs @@ -1,2 +1,2 @@ #[cfg(feature = "metadata")] -include!(concat!(env!("OUT_DIR"), "/rhai-sci-tests.rs")); \ No newline at end of file +include!(concat!(env!("OUT_DIR"), "/rhai-sci-tests.rs")); From 3c644d6a3dd7fb5f2905e6708928afd5c15cd845 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 20:35:45 -0400 Subject: [PATCH 09/22] Add matrix inversion example --- README.md | 9 +++++++++ examples/matrix_inversion.rhai | 4 ++++ examples/matrix_inversion.rs | 21 +++++++++++++++++++++ tests/matrix_inverse_example.rs | 24 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 examples/matrix_inversion.rhai create mode 100644 examples/matrix_inversion.rs create mode 100644 tests/matrix_inverse_example.rs diff --git a/README.md b/README.md index e769163..050eada 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ engine.register_global_module(SciPackage::new().as_shared_module()); let value = engine.eval::("argmin([43, 42, -500])").unwrap(); ``` +## Examples + +The `examples/` directory contains runnable snippets that showcase `rhai-sci` in action: + +- `download_and_regress.rs` – download CSV data and perform a linear regression. +- `matrix_inversion.rs` – compute the inverse of a small matrix using `inv`. + +Run any example with `cargo run --example `. + ## Matrix & Vector Conventions Matrices use the conventional `n×m` shape where `n` is the number of rows and `m` diff --git a/examples/matrix_inversion.rhai b/examples/matrix_inversion.rhai new file mode 100644 index 0000000..d70740e --- /dev/null +++ b/examples/matrix_inversion.rhai @@ -0,0 +1,4 @@ +let m = [[1, 2], [3, 4]]; +let inv_m = inv(m); +print(inv_m); +inv_m diff --git a/examples/matrix_inversion.rs b/examples/matrix_inversion.rs new file mode 100644 index 0000000..422d275 --- /dev/null +++ b/examples/matrix_inversion.rs @@ -0,0 +1,21 @@ +//! Demonstrates computing the inverse of a matrix using rhai-sci. + +fn main() { + #[cfg(feature = "nalgebra")] + { + use rhai::{packages::Package, Engine}; + use rhai_sci::SciPackage; + + // Create a new Rhai engine + let mut engine = Engine::new(); + + // Add the rhai-sci package to the engine + engine.register_global_module(SciPackage::new().as_shared_module()); + + // Run the script that inverts a matrix + let result = engine + .run_file("examples/matrix_inversion.rhai".into()) + .expect("script should run"); + println!("{:?}", result); + } +} diff --git a/tests/matrix_inverse_example.rs b/tests/matrix_inverse_example.rs new file mode 100644 index 0000000..593c8a6 --- /dev/null +++ b/tests/matrix_inverse_example.rs @@ -0,0 +1,24 @@ +#![cfg(feature = "nalgebra")] +use rhai::{packages::Package, Array, Engine}; +use rhai_sci::SciPackage; + +#[test] +fn matrix_inverse_example_produces_expected_result() { + let mut engine = Engine::new(); + engine.register_global_module(SciPackage::new().as_shared_module()); + + let result: Array = engine + .eval("inv([[1, 2], [3, 4]])") + .expect("script evaluation should succeed"); + + let first_row: Array = result[0].clone().cast::(); + let second_row: Array = result[1].clone().cast::(); + + let r0: Vec = first_row.into_iter().map(|v| v.cast::()).collect(); + let r1: Vec = second_row.into_iter().map(|v| v.cast::()).collect(); + + assert!((r0[0] + 2.0).abs() < f64::EPSILON); + assert!((r0[1] - 1.0).abs() < f64::EPSILON); + assert!((r1[0] - 1.5).abs() < f64::EPSILON); + assert!((r1[1] + 0.5).abs() < f64::EPSILON); +} From f305361fc0eb1ff07c0fe528d61a11c0c85e840c Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 7 Sep 2025 20:38:50 -0400 Subject: [PATCH 10/22] ci: remove clippy lint --- .github/workflows/tests.yml | 4 +--- README.md | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4f01e1c..92a8af4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,12 +18,10 @@ jobs: with: toolchain: stable override: true - components: clippy, rustfmt + components: rustfmt - uses: Swatinem/rust-cache@v2 - name: Format run: cargo fmt --all -- --check - - name: Lint - run: cargo clippy --all-targets --no-default-features --features rand,nalgebra -- -D warnings -D clippy::pedantic - name: Test run: cargo test --no-default-features --features rand,nalgebra diff --git a/README.md b/README.md index 72b541c..1be6faf 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ The full API is documented on [docs.rs](https://docs.rs/rhai-sci). ```bash cargo fmt --all -- --check -cargo clippy --all-targets --no-default-features --features rand,nalgebra -- -D warnings -D clippy::pedantic cargo test --no-default-features --features rand,nalgebra ``` From 056b5369b137a07c372e9458d701f7760efe400b Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Thu, 18 Sep 2025 13:55:01 -0400 Subject: [PATCH 11/22] test: cover projectile motion example --- README.md | 6 ++++++ examples/projectile_motion.rhai | 33 ++++++++++++++++++++++++++++++ examples/projectile_motion.rs | 14 +++++++++++++ tests/projectile_motion_example.rs | 28 +++++++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 examples/projectile_motion.rhai create mode 100644 examples/projectile_motion.rs create mode 100644 tests/projectile_motion_example.rs diff --git a/README.md b/README.md index 713453b..df2e6ed 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ engine.register_global_module(SciPackage::new().as_shared_module()); let value = engine.eval::("argmin([43, 42, -500])").unwrap(); ``` +See the `examples` directory for more: + +- `matrix_inversion.rhai` demonstrates matrix inversion. +- `download_and_regress.rhai` fetches data and performs linear regression. +- `projectile_motion.rhai` uses trigonometry and array utilities to simulate a projectile trajectory. + ### Features - **metadata** *(disabled)*: export function metadata; required for running doc-tests on Rhai examples. diff --git a/examples/projectile_motion.rhai b/examples/projectile_motion.rhai new file mode 100644 index 0000000..3b49c64 --- /dev/null +++ b/examples/projectile_motion.rhai @@ -0,0 +1,33 @@ +// Simulate simple projectile motion using rhai-sci functions +let g = 9.81; // gravitational acceleration (m/s^2) +let v0 = 25.0; // launch speed (m/s) +let angle = 45.0; // launch angle (degrees) + +// Generate a time vector from 0 to total flight time +let t_flight = 2.0 * v0 * sind(angle) / g; +let times = linspace(0.0, t_flight, 50); + +let vx = v0 * cosd(angle); +let vy0 = v0 * sind(angle); + +let x = []; +let y = []; + +for t in times { + x.push(vx * t); + y.push(vy0 * t - 0.5 * g * t * t); +} + +let max_y = max(y); +let idx = argmax(y); +let peak_time = times[idx]; +let range = max(x); + +let result = #{ + "max_height": max_y, + "time_of_flight": t_flight, + "peak_time": peak_time, + "range": range +}; +print(result); +result diff --git a/examples/projectile_motion.rs b/examples/projectile_motion.rs new file mode 100644 index 0000000..9e1f8bd --- /dev/null +++ b/examples/projectile_motion.rs @@ -0,0 +1,14 @@ +//! Simulates projectile motion using rhai-sci. + +fn main() { + use rhai::{packages::Package, Engine}; + use rhai_sci::SciPackage; + + let mut engine = Engine::new(); + engine.register_global_module(SciPackage::new().as_shared_module()); + + let result: rhai::Map = engine + .eval_file("examples/projectile_motion.rhai".into()) + .expect("script should run"); + println!("{result:?}"); +} diff --git a/tests/projectile_motion_example.rs b/tests/projectile_motion_example.rs new file mode 100644 index 0000000..64f49b9 --- /dev/null +++ b/tests/projectile_motion_example.rs @@ -0,0 +1,28 @@ +use rhai::{packages::Package, Engine, Map}; +use rhai_sci::SciPackage; + +#[test] +fn projectile_motion_example_produces_expected_result() { + // Arrange: set up engine with rhai-sci package + let mut engine = Engine::new(); + engine.register_global_module(SciPackage::new().as_shared_module()); + + // Act: evaluate projectile motion script + let result: Map = engine + .eval_file("examples/projectile_motion.rhai".into()) + .expect("script evaluation should succeed"); + + // Assert: compare against analytical solution + let max_height = result["max_height"].clone().cast::(); + let time_of_flight = result["time_of_flight"].clone().cast::(); + let range = result["range"].clone().cast::(); + + let expected_max_height = + (25.0_f64.powi(2) * (45_f64.to_radians().sin().powi(2))) / (2.0 * 9.81); + let expected_time_of_flight = 2.0 * 25.0 * 45_f64.to_radians().sin() / 9.81; + let expected_range = (25.0_f64.powi(2) * (2.0 * 45_f64.to_radians()).sin()) / 9.81; + + assert!((max_height - expected_max_height).abs() < 1e-2); + assert!((time_of_flight - expected_time_of_flight).abs() < 1e-6); + assert!((range - expected_range).abs() < 1e-6); +} From b0bc83d033d3f9d9f63c0ad219c65cf9e678c359 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sat, 15 Nov 2025 19:36:16 -0500 Subject: [PATCH 12/22] Fix meshgrid broadcasting and add tests --- src/matrices_and_arrays.rs | 20 +++++++++++---- tests/matrix_ops.rs | 50 +++++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index de637f8..785f2db 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -1051,6 +1051,12 @@ pub mod matrix_functions { /// [1, 2]], /// "y": [[3.0, 3.0], /// [4.0, 4.0]]}); + /// + /// let x = [0, 1, 2]; + /// let y = [10]; + /// let g = meshgrid(x, y); + /// assert_eq(g, #{"x": [[0, 1, 2]], + /// "y": [[10.0, 10.0, 10.0]]}); /// ``` #[rhai_fn(name = "meshgrid", return_raw)] pub fn meshgrid(x: Array, y: Array) -> Result> { @@ -1058,8 +1064,14 @@ pub mod matrix_functions { if_list_do(&mut y.clone(), |y| { let nx = x.len(); let ny = y.len(); - let x_dyn: Array = vec![Dynamic::from_array(x.to_vec()); nx]; - let y_dyn: Array = vec![Dynamic::from_array(y.to_vec()); ny]; + let x_dyn: Array = (0..ny).map(|_| Dynamic::from_array(x.to_vec())).collect(); + let y_dyn: Array = y + .iter() + .map(|value| { + let y_row: Array = (0..nx).map(|_| value.clone()).collect(); + Dynamic::from_array(y_row) + }) + .collect(); let mut result = BTreeMap::new(); let mut xid = smartstring::SmartString::new(); @@ -1067,9 +1079,7 @@ pub mod matrix_functions { let mut yid = smartstring::SmartString::new(); yid.push_str("y"); result.insert(xid, Dynamic::from_array(x_dyn)); - let y_matrix = RhaiMatrix::from_array(y_dyn); - let y_t = transpose(y_matrix)?.to_array(); - result.insert(yid, Dynamic::from_array(y_t)); + result.insert(yid, Dynamic::from_array(y_dyn)); Ok(result) }) }) diff --git a/tests/matrix_ops.rs b/tests/matrix_ops.rs index 2a8bc7a..5bce345 100644 --- a/tests/matrix_ops.rs +++ b/tests/matrix_ops.rs @@ -1,6 +1,8 @@ use rhai::{Array, Dynamic, FLOAT, INT}; use rhai_sci::matrix::RhaiMatrix; -use rhai_sci::matrix_functions::{horzcat, matrix_size_by_reference, repmat, transpose, vertcat}; +use rhai_sci::matrix_functions::{ + horzcat, matrix_size_by_reference, meshgrid, repmat, transpose, vertcat, +}; use rhai_sci::validation_functions::{is_column_vector, is_row_vector}; #[test] @@ -56,3 +58,49 @@ fn repmat_replicates_matrix() { let dims: Vec = shape.into_iter().map(|d| d.as_int().unwrap()).collect(); assert_eq!(dims, vec![4, 4]); } + +#[test] +fn meshgrid_matches_matlab_shape_for_mismatched_lengths() { + let x: Array = vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ]; + let y: Array = vec![Dynamic::from_int(4), Dynamic::from_int(5)]; + let grid = meshgrid(x.clone(), y.clone()).unwrap(); + + let x_grid = grid.get("x").unwrap().clone().into_array().unwrap(); + let y_grid = grid.get("y").unwrap().clone().into_array().unwrap(); + + let mut x_grid_for_size = x_grid.clone(); + let x_shape = matrix_size_by_reference(&mut x_grid_for_size); + let x_dims: Vec = x_shape.into_iter().map(|d| d.as_int().unwrap()).collect(); + assert_eq!(x_dims, vec![2, 3]); + + let mut y_grid_for_size = y_grid.clone(); + let y_shape = matrix_size_by_reference(&mut y_grid_for_size); + let y_dims: Vec = y_shape.into_iter().map(|d| d.as_int().unwrap()).collect(); + assert_eq!(y_dims, vec![2, 3]); + + for row in x_grid.into_iter() { + let row_values: Vec = row + .into_array() + .unwrap() + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect(); + assert_eq!(row_values, vec![1, 2, 3]); + } + + let y_rows: Vec> = y_grid + .into_iter() + .map(|row| { + row.into_array() + .unwrap() + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect() + }) + .collect(); + assert_eq!(y_rows, vec![vec![4, 4, 4], vec![5, 5, 5]]); +} From 9e1a7d5460d6cdf9d96faa58e46ae6852bf1adfa Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sat, 15 Nov 2025 19:36:22 -0500 Subject: [PATCH 13/22] Fix eye single argument array handling --- src/matrices_and_arrays.rs | 47 +++++++++++++++++++++------------- tests/orientation.rs | 52 +++++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index de637f8..199b118 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -675,6 +675,9 @@ pub mod matrix_functions { /// Returns an identity matrix. If argument is a single number, then the output is /// a square matrix. The argument can also be an array specifying the dimensions separately. + /// Passing `[n]` is equivalent to `eye(n)` (a square `n x n` matrix) while `[rows, cols]` + /// creates a rectangular matrix with the provided row and column counts. Any other shape is + /// rejected. /// ```typescript /// let matrix = eye(3); /// assert_eq(matrix, [[1.0, 0.0, 0.0], @@ -689,28 +692,36 @@ pub mod matrix_functions { /// ``` #[rhai_fn(name = "eye", return_raw)] pub fn eye_single_input(n: Dynamic) -> Result> { + fn parse_eye_dimension(value: &Dynamic) -> Result> { + value.as_int().map_err(|_| { + EvalAltResult::ErrorMismatchDataType( + "Size vector for eye must contain integers".to_string(), + String::new(), + Position::NONE, + ) + .into() + }) + } + if_int_do_else_if_array_do( n, |n| Ok(eye_double_input(n, n)), - |m| { - if m.len() == 1 { - Ok(eye_double_input(1, m[0].as_int().unwrap())[0] - .clone() - .into_array() - .unwrap()) - } else if m.len() == 2 { - Ok(eye_double_input( - m[0].as_int().unwrap(), - m[1].as_int().unwrap(), - )) - } else { - Err(EvalAltResult::ErrorMismatchDataType( - format!("Cannot create an identity matrix with more than 2 dimensions"), - format!(""), - Position::NONE, - ) - .into()) + |m| match m.len() { + 1 => { + let size = parse_eye_dimension(&m[0])?; + Ok(eye_double_input(size, size)) + } + 2 => { + let rows = parse_eye_dimension(&m[0])?; + let cols = parse_eye_dimension(&m[1])?; + Ok(eye_double_input(rows, cols)) } + _ => Err(EvalAltResult::ErrorMismatchDataType( + "Cannot create an identity matrix with more than 2 dimensions".to_string(), + String::new(), + Position::NONE, + ) + .into()), }, ) } diff --git a/tests/orientation.rs b/tests/orientation.rs index abbde0a..eeb3bd4 100644 --- a/tests/orientation.rs +++ b/tests/orientation.rs @@ -1,5 +1,6 @@ -use rhai::{Array, Dynamic}; +use rhai::{Array, Dynamic, FLOAT}; use rhai_sci::matrix::RhaiMatrix; +use rhai_sci::matrix_functions; use rhai_sci::validation_functions::{is_column_vector, is_row_vector}; #[test] @@ -32,3 +33,52 @@ fn validate_orientation_helpers() { assert!(is_column_vector(&mut column.clone())); assert!(!is_column_vector(&mut row)); } + +#[test] +fn eye_vector_size_matches_scalar_size() { + for size in [1, 2, 5] { + let scalar = matrix_functions::eye_single_input(Dynamic::from_int(size)) + .expect("scalar eye should succeed"); + let vector = + matrix_functions::eye_single_input(Dynamic::from_array(vec![Dynamic::from_int(size)])) + .expect("vector eye should succeed"); + assert_eq!( + normalize_matrix(&scalar), + normalize_matrix(&vector), + "size {size} should match", + ); + } +} + +#[test] +fn eye_vector_two_dimensions_remains_rectangular() { + let rectangular = matrix_functions::eye_single_input(Dynamic::from_array(vec![ + Dynamic::from_int(2), + Dynamic::from_int(3), + ])) + .expect("rectangular eye should succeed"); + assert_eq!( + normalize_matrix(&rectangular), + normalize_matrix(&matrix_functions::eye_double_input(2, 3)), + ); +} + +fn normalize_matrix(matrix: &Array) -> Vec> { + matrix + .iter() + .map(|row| { + row.clone() + .into_array() + .expect("matrix rows should be arrays") + .into_iter() + .map(|value| { + if value.is_float() { + value.as_float().expect("value is float") + } else { + value.as_int().expect("value is int") as FLOAT + } + }) + .collect() + }) + .collect() +} From 2babadcdbef8d72afef871fca1c47fb55dbef936 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sat, 15 Nov 2025 20:12:43 -0500 Subject: [PATCH 14/22] Fix concatenation orientation checks --- src/matrices_and_arrays.rs | 8 ++----- tests/matrix_ops.rs | 48 +++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 260b7c5..920ed31 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -921,9 +921,7 @@ pub mod matrix_functions { matrix1: RhaiMatrix, matrix2: RhaiMatrix, ) -> Result> { - let left = matrix1.as_row().unwrap_or(matrix1); - let right = matrix2.as_row().unwrap_or(matrix2); - left.concat_h(&right) + matrix1.concat_h(&matrix2) } #[cfg(feature = "nalgebra")] @@ -949,9 +947,7 @@ pub mod matrix_functions { matrix1: RhaiMatrix, matrix2: RhaiMatrix, ) -> Result> { - let top = matrix1.as_column().unwrap_or(matrix1); - let bottom = matrix2.as_column().unwrap_or(matrix2); - top.concat_v(&bottom) + matrix1.concat_v(&matrix2) } #[cfg(feature = "nalgebra")] diff --git a/tests/matrix_ops.rs b/tests/matrix_ops.rs index 5bce345..bcf974f 100644 --- a/tests/matrix_ops.rs +++ b/tests/matrix_ops.rs @@ -1,4 +1,4 @@ -use rhai::{Array, Dynamic, FLOAT, INT}; +use rhai::{Array, Dynamic, EvalAltResult, FLOAT, INT}; use rhai_sci::matrix::RhaiMatrix; use rhai_sci::matrix_functions::{ horzcat, matrix_size_by_reference, meshgrid, repmat, transpose, vertcat, @@ -30,6 +30,29 @@ fn horzcat_concatenates_rows() { assert_eq!(values, vec![1.0, 2.0, 3.0, 4.0]); } +#[test] +fn horzcat_column_vectors_result_in_matrix_with_two_columns() { + let a = RhaiMatrix::column_vector(vec![Dynamic::from_int(1), Dynamic::from_int(2)]); + let b = RhaiMatrix::column_vector(vec![Dynamic::from_int(3), Dynamic::from_int(4)]); + let mut result = horzcat(a, b).unwrap().to_array(); + let shape = matrix_size_by_reference(&mut result); + let dims: Vec = shape.into_iter().map(|d| d.as_int().unwrap()).collect(); + assert_eq!(dims, vec![2, 2]); +} + +#[test] +fn horzcat_mixed_shapes_error_out() { + let row = RhaiMatrix::row_vector(vec![Dynamic::from_int(1), Dynamic::from_int(2)]); + let column = RhaiMatrix::column_vector(vec![Dynamic::from_int(3), Dynamic::from_int(4)]); + let err = horzcat(row, column).unwrap_err(); + match err.as_ref() { + EvalAltResult::ErrorArithmetic(message, _) => { + assert!(message.contains("same number of rows")); + } + other => panic!("unexpected error: {:?}", other), + } +} + #[test] fn vertcat_concatenates_columns() { let a: Array = vec![ @@ -46,6 +69,29 @@ fn vertcat_concatenates_columns() { assert!(is_column_vector(&mut result)); } +#[test] +fn vertcat_row_vectors_result_in_matrix_with_two_rows() { + let m1 = RhaiMatrix::row_vector(vec![Dynamic::from_int(1), Dynamic::from_int(2)]); + let m2 = RhaiMatrix::row_vector(vec![Dynamic::from_int(3), Dynamic::from_int(4)]); + let mut result = vertcat(m1, m2).unwrap().to_array(); + let shape = matrix_size_by_reference(&mut result); + let dims: Vec = shape.into_iter().map(|d| d.as_int().unwrap()).collect(); + assert_eq!(dims, vec![2, 2]); +} + +#[test] +fn vertcat_mixed_shapes_error_out() { + let column = RhaiMatrix::column_vector(vec![Dynamic::from_int(1), Dynamic::from_int(2)]); + let row = RhaiMatrix::row_vector(vec![Dynamic::from_int(3), Dynamic::from_int(4)]); + let err = vertcat(column, row).unwrap_err(); + match err.as_ref() { + EvalAltResult::ErrorArithmetic(message, _) => { + assert!(message.contains("same number of columns")); + } + other => panic!("unexpected error: {:?}", other), + } +} + #[test] fn repmat_replicates_matrix() { let data: Array = vec![ From 776e0632d2d831adce47a45bd9bd0913d7b092ab Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sat, 15 Nov 2025 20:13:24 -0500 Subject: [PATCH 15/22] Handle vector inputs in diag --- src/matrices_and_arrays.rs | 63 ++++++++++++++++++++++++-------------- tests/matrix_ops.rs | 63 +++++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 260b7c5..b3ae9a6 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -4,6 +4,7 @@ use rhai::plugin::*; pub mod matrix_functions { #[cfg(feature = "nalgebra")] use crate::matrix::{RhaiMatrix, RhaiVector}; + use crate::validation_functions::{is_column_vector, is_row_vector}; use crate::{ array_to_vec_float, if_int_convert_to_float_and_do, if_int_do_else_if_array_do, if_list_do, if_matrix_convert_to_vec_array_and_do, @@ -984,44 +985,60 @@ pub mod matrix_functions { /// ``` #[rhai_fn(name = "diag", return_raw)] pub fn diag(matrix: Array) -> Result> { - if ndims_by_reference(&mut matrix.clone()) == 2 { - // Turn into Vec + let dims = ndims_by_reference(&mut matrix.clone()); + if dims == 2 { + let mut candidate_for_row = matrix.clone(); + let mut candidate_for_col = matrix.clone(); + if is_row_vector(&mut candidate_for_row) || is_column_vector(&mut candidate_for_col) { + let mut flattened_vector = matrix.clone(); + let vector = flatten(&mut flattened_vector); + return Ok(diagonal_matrix_from_vector(vector)); + } + let matrix_as_vec = matrix .into_iter() .map(|x| x.into_array().unwrap()) .collect::>(); - let mut out = vec![]; - for i in 0..matrix_as_vec.len() { - out.push(matrix_as_vec[i][i].clone()); + if matrix_as_vec.is_empty() { + return Ok(vec![]); } - Ok(out) - } else if ndims_by_reference(&mut matrix.clone()) == 1 { + let cols = matrix_as_vec[0].len(); + let diag_len = matrix_as_vec.len().min(cols); let mut out = vec![]; - for idx in 0..matrix.len() { - let mut new_row = vec![]; - for jdx in 0..matrix.len() { - if idx == jdx { - new_row.push(matrix[idx].clone()); - } else { - if matrix[idx].is_int() { - new_row.push(Dynamic::ZERO); - } else { - new_row.push(Dynamic::FLOAT_ZERO); - } - } - } - out.push(Dynamic::from_array(new_row)); + for i in 0..diag_len { + out.push(matrix_as_vec[i][i].clone()); } + Ok(out) + } else if dims == 1 { + Ok(diagonal_matrix_from_vector(matrix)) } else { - return Err(EvalAltResult::ErrorArithmetic( + Err(EvalAltResult::ErrorArithmetic( "Argument must be a 2-D matrix (to extract the diagonal) or a 1-D array (to create a matrix with that diagonal".to_string(), Position::NONE, ) - .into()); + .into()) + } + } + + fn diagonal_matrix_from_vector(vector: Array) -> Array { + let mut out = vec![]; + for idx in 0..vector.len() { + let mut new_row = vec![]; + for jdx in 0..vector.len() { + if idx == jdx { + new_row.push(vector[idx].clone()); + } else if vector[idx].is_int() { + new_row.push(Dynamic::ZERO); + } else { + new_row.push(Dynamic::FLOAT_ZERO); + } + } + out.push(Dynamic::from_array(new_row)); } + out } /// Repeats copies of a matrix diff --git a/tests/matrix_ops.rs b/tests/matrix_ops.rs index 5bce345..8092a32 100644 --- a/tests/matrix_ops.rs +++ b/tests/matrix_ops.rs @@ -1,7 +1,7 @@ use rhai::{Array, Dynamic, FLOAT, INT}; use rhai_sci::matrix::RhaiMatrix; use rhai_sci::matrix_functions::{ - horzcat, matrix_size_by_reference, meshgrid, repmat, transpose, vertcat, + diag, horzcat, matrix_size_by_reference, meshgrid, repmat, transpose, vertcat, }; use rhai_sci::validation_functions::{is_column_vector, is_row_vector}; @@ -104,3 +104,64 @@ fn meshgrid_matches_matlab_shape_for_mismatched_lengths() { .collect(); assert_eq!(y_rows, vec![vec![4, 4, 4], vec![5, 5, 5]]); } + +fn matrix_to_ints(matrix: Array) -> Vec> { + matrix + .into_iter() + .map(|row| { + row.into_array() + .unwrap() + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect() + }) + .collect() +} + +#[test] +fn diag_treats_row_and_column_vectors_equally() { + let column: Array = vec![ + Dynamic::from_array(vec![Dynamic::from_int(1)]), + Dynamic::from_array(vec![Dynamic::from_int(2)]), + Dynamic::from_array(vec![Dynamic::from_int(3)]), + ]; + let row: Array = vec![Dynamic::from_array(vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ])]; + + let column_diag = diag(column).unwrap(); + let row_diag = diag(row).unwrap(); + + let column_values = matrix_to_ints(column_diag.clone()); + let row_values = matrix_to_ints(row_diag.clone()); + assert_eq!(column_values, row_values); + + let expected = vec![vec![1, 0, 0], vec![0, 2, 0], vec![0, 0, 3]]; + assert_eq!(row_values, expected); +} + +#[test] +fn diag_extracts_from_rectangular_matrices() { + let matrix: Array = vec![ + Dynamic::from_array(vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ]), + Dynamic::from_array(vec![ + Dynamic::from_int(4), + Dynamic::from_int(5), + Dynamic::from_int(6), + ]), + ]; + + let diag_values = diag(matrix).unwrap(); + let ints: Vec = diag_values + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect(); + let expected = vec![1, 5]; + assert_eq!(ints, expected); +} From ad2b105cbd0cb0debcfd0e325d65332863edc364 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sat, 15 Nov 2025 20:13:28 -0500 Subject: [PATCH 16/22] Allow vector-shaped inputs in list helpers --- src/patterns.rs | 54 +++++++++++++++++++++++---- src/validate.rs | 12 ++++-- tests/list_like_inputs.rs | 51 ++++++++++++++++++++++++++ tests/matrix_ops.rs | 77 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 tests/list_like_inputs.rs diff --git a/src/patterns.rs b/src/patterns.rs index 1d829e3..df77212 100644 --- a/src/patterns.rs +++ b/src/patterns.rs @@ -65,18 +65,58 @@ where } } +fn list_error(message: &str) -> Box { + EvalAltResult::ErrorArithmetic(message.to_string(), Position::NONE).into() +} + +fn normalize_numeric_list(arr: &mut Array) -> Result> { + if arr.len() == 1 { + arr.first() + .ok_or_else(|| list_error("Row vector inputs must contain scalar values."))? + .clone() + .into_array() + .map_err(|_| list_error("Row vector inputs must contain scalar values.")) + } else { + arr.iter() + .map(|row| { + row.clone() + .into_array() + .map_err(|_| list_error("Column vector inputs must contain scalar values.")) + .and_then(|inner| { + inner.into_iter().next().ok_or_else(|| { + list_error("Column vector inputs must contain scalar values.") + }) + }) + }) + .collect::>() + } +} + /// Does a function if the input is a list, otherwise throws an error. pub fn if_list_do(arr: &mut Array, mut f: F) -> Result> where F: FnMut(&mut Array) -> Result>, { - crate::validation_functions::is_numeric_list(arr) - .then(|| f(arr)) - .unwrap_or(Err(EvalAltResult::ErrorArithmetic( - format!("The elements of the input array must either be INT or FLOAT."), - Position::NONE, - ) - .into())) + if !crate::validation_functions::is_list(arr) { + return Err(list_error( + "Input must be a 1-D array, row vector, or column vector.", + )); + } + + let (int, float, total) = int_and_float_totals(arr); + if !(int == total || float == total) { + return Err(list_error( + "The elements of the input array must either be INT or FLOAT.", + )); + } + + let needs_normalization = crate::matrix_functions::matrix_size_by_reference(arr).len() == 2; + if needs_normalization { + let mut normalized = normalize_numeric_list(arr)?; + f(&mut normalized) + } else { + f(arr) + } } pub fn if_list_convert_to_vec_float_and_do( diff --git a/src/validate.rs b/src/validate.rs index b47dbd1..21a9332 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -5,7 +5,7 @@ pub mod validation_functions { use crate::matrix::RhaiMatrix; use rhai::{Array, Dynamic}; - /// Tests whether the input in a simple list array + /// Tests whether the input is a simple list or a numeric vector. /// ```typescript /// let x = [1, 2, 3, 4]; /// assert_eq(is_list(x), true); @@ -16,8 +16,13 @@ pub mod validation_functions { /// ``` #[rhai_fn(name = "is_list", pure)] pub fn is_list(arr: &mut Array) -> bool { - if crate::matrix_functions::matrix_size_by_reference(arr).len() == 1 { + let shape = crate::matrix_functions::matrix_size_by_reference(arr); + if shape.len() == 1 { true + } else if shape.len() == 2 { + let (ints, floats, total) = crate::int_and_float_totals(arr); + let numeric = ints + floats == total; + numeric && (is_row_vector(arr) || is_column_vector(arr)) } else { false } @@ -80,7 +85,8 @@ pub mod validation_functions { }; } - /// Tests whether the input in a simple list array composed of either floating point or integer values. + /// Tests whether the input in a simple list array composed of either floating point or integer values + /// or a numeric row/column vector. /// ```typescript /// let x = [1.0, 2.0, 3.0, 4.0]; /// assert_eq(is_numeric_list(x), true) diff --git a/tests/list_like_inputs.rs b/tests/list_like_inputs.rs new file mode 100644 index 0000000..0f0c68e --- /dev/null +++ b/tests/list_like_inputs.rs @@ -0,0 +1,51 @@ +use rhai::{Array, Dynamic, FLOAT}; +use rhai_sci::moving_functions::movmean; +use rhai_sci::stats::argmax; + +fn row_vector(values: &[i64]) -> Array { + let row: Array = values + .iter() + .map(|value| Dynamic::from_int(*value)) + .collect(); + vec![Dynamic::from_array(row)] +} + +fn column_vector(values: &[i64]) -> Array { + values + .iter() + .map(|value| Dynamic::from_array(vec![Dynamic::from_int(*value)])) + .collect() +} + +fn array_to_floats(values: Array) -> Vec { + values + .into_iter() + .map(|value| value.as_float().unwrap()) + .collect() +} + +#[test] +fn movmean_accepts_row_and_column_vectors() { + let mut row = row_vector(&[1, 2, 3, 4]); + let mut column = column_vector(&[1, 2, 3, 4]); + + let expected = vec![1.5, 2.0, 3.0, 3.5]; + + let row_result = movmean(&mut row, 3).unwrap(); + assert_eq!(array_to_floats(row_result), expected); + + let column_result = movmean(&mut column, 3).unwrap(); + assert_eq!(array_to_floats(column_result), expected); +} + +#[test] +fn argmax_accepts_row_and_column_vectors() { + let mut row = row_vector(&[1, 9, 3]); + let mut column = column_vector(&[1, 9, 3]); + + let row_index = argmax(&mut row).unwrap(); + let column_index = argmax(&mut column).unwrap(); + + assert_eq!(row_index.as_int().unwrap(), 1); + assert_eq!(column_index.as_int().unwrap(), 1); +} diff --git a/tests/matrix_ops.rs b/tests/matrix_ops.rs index 5bce345..f38deab 100644 --- a/tests/matrix_ops.rs +++ b/tests/matrix_ops.rs @@ -104,3 +104,80 @@ fn meshgrid_matches_matlab_shape_for_mismatched_lengths() { .collect(); assert_eq!(y_rows, vec![vec![4, 4, 4], vec![5, 5, 5]]); } + +#[test] +fn meshgrid_accepts_row_vector_input() { + let row: Array = vec![Dynamic::from_array(vec![ + Dynamic::from_int(0), + Dynamic::from_int(1), + Dynamic::from_int(2), + ])]; + let y: Array = vec![Dynamic::from_int(3), Dynamic::from_int(4)]; + + let grid = meshgrid(row, y).unwrap(); + + let x_grid = grid.get("x").unwrap().clone().into_array().unwrap(); + let y_grid = grid.get("y").unwrap().clone().into_array().unwrap(); + + for row in x_grid.into_iter() { + let values: Vec = row + .into_array() + .unwrap() + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect(); + assert_eq!(values, vec![0, 1, 2]); + } + + let y_rows: Vec> = y_grid + .into_iter() + .map(|row| { + row.into_array() + .unwrap() + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect() + }) + .collect(); + assert_eq!(y_rows, vec![vec![3, 3, 3], vec![4, 4, 4]]); +} + +#[test] +fn meshgrid_accepts_column_vector_inputs() { + let column_x: Array = vec![ + Dynamic::from_array(vec![Dynamic::from_int(0)]), + Dynamic::from_array(vec![Dynamic::from_int(1)]), + Dynamic::from_array(vec![Dynamic::from_int(2)]), + ]; + let column_y: Array = vec![ + Dynamic::from_array(vec![Dynamic::from_int(3)]), + Dynamic::from_array(vec![Dynamic::from_int(4)]), + ]; + + let grid = meshgrid(column_x, column_y).unwrap(); + + let x_grid = grid.get("x").unwrap().clone().into_array().unwrap(); + let y_grid = grid.get("y").unwrap().clone().into_array().unwrap(); + + for row in x_grid.into_iter() { + let values: Vec = row + .into_array() + .unwrap() + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect(); + assert_eq!(values, vec![0, 1, 2]); + } + + let y_rows: Vec> = y_grid + .into_iter() + .map(|row| { + row.into_array() + .unwrap() + .into_iter() + .map(|d| d.as_int().unwrap()) + .collect() + }) + .collect(); + assert_eq!(y_rows, vec![vec![3, 3, 3], vec![4, 4, 4]]); +} From f674b152154a075175e25ef3d66cb24e60399ee2 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Mon, 8 Jun 2026 20:23:32 -0400 Subject: [PATCH 17/22] Add matrix convention helpers --- README.md | 36 ++++- src/matrices_and_arrays.rs | 264 +++++++++++++++++++++++++++++++++++- tests/matrix_conventions.rs | 110 +++++++++++++++ 3 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 tests/matrix_conventions.rs diff --git a/README.md b/README.md index b90d1cb..224df6d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,41 @@ See the `examples` directory for more: - `download_and_regress.rhai` fetches data and performs linear regression. - `projectile_motion.rhai` uses trigonometry and array utilities to simulate a projectile trajectory. +## Matrix and Vector Conventions + +Rhai arrays remain the storage model. Use constructors when shape matters: + +```typescript +let values = [1, 2, 3]; // plain Rhai list +let x = vec([1, 2, 3]); // column vector: [[1], [2], [3]] +let c = col([1, 2, 3]); // column vector: [[1], [2], [3]] +let r = row([1, 2, 3]); // row vector: [[1, 2, 3]] +let A = mat([[1, 2], [3, 4]]); // validated matrix +``` + +For compact matrix literals, use strings with whitespace, commas, and semicolons: + +```typescript +let A = mat("1 2; 3 4"); +let B = M("1, 2; 3, 4"); +let r = R("1 2 3"); +let c = C("1; 2; 3"); +``` + +The short aliases keep linear algebra scripts readable: + +```typescript +let A = mat("1 2; 3 4"); +let x = col([5, 6]); +let y = row([7, 8]); + +let z = dot(A, x); +let z2 = A.dot(x); +let At = T(A); +let C = hcat(A, x); +let D = vcat(A, y); +``` + ### Features - **metadata** *(disabled)*: export function metadata; required for running doc-tests on Rhai examples. @@ -75,4 +110,3 @@ Licensed under either of - [Apache License, Version 2.0](LICENSE-APACHE.txt) at your option. - diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 4f6f952..772c30f 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -1,8 +1,135 @@ use rhai::plugin::*; +mod matrix_conventions { + use rhai::{Array, Dynamic, EvalAltResult, ImmutableString, Position, FLOAT, INT}; + + pub(super) fn vector_data_from_array( + values: Array, + constructor: &str, + ) -> Result> { + if values.iter().all(is_numeric_scalar) { + return Ok(values); + } + + let mut rows = Vec::with_capacity(values.len()); + for row in values { + rows.push(row.into_array().map_err(|_| { + matrix_error(format!( + "{constructor} expects a numeric list, row vector, or column vector" + )) + })?); + } + + if rows.len() == 1 { + ensure_numeric_list(&rows[0], constructor)?; + return Ok(rows.remove(0)); + } + + if rows.iter().all(|row| { + row.len() == 1 && matches!(row.first(), Some(value) if is_numeric_scalar(value)) + }) { + return Ok(rows.into_iter().map(|mut row| row.remove(0)).collect()); + } + + Err(matrix_error(format!( + "{constructor} expects a numeric list, row vector, or column vector" + ))) + } + + pub(super) fn parse_matrix_literal( + source: ImmutableString, + ) -> Result> { + let mut matrix = Array::new(); + let source = source.as_str(); + if source.trim().is_empty() { + return Err(matrix_error("Matrix literal must not be empty")); + } + + for row_text in source.split(';') { + let row_text = row_text.trim(); + if row_text.is_empty() { + return Err(matrix_error("Matrix literal rows must not be empty")); + } + + let normalized = row_text.replace(',', " "); + let mut row = Array::new(); + for token in normalized.split_whitespace() { + row.push(parse_numeric_token(token)?); + } + + if row.is_empty() { + return Err(matrix_error("Matrix literal rows must not be empty")); + } + matrix.push(Dynamic::from_array(row)); + } + + ensure_numeric_matrix(&matrix)?; + Ok(matrix) + } + + fn parse_numeric_token(token: &str) -> Result> { + if !token.contains('.') && !token.contains('e') && !token.contains('E') { + if let Ok(value) = token.parse::() { + return Ok(Dynamic::from_int(value)); + } + } + + token + .parse::() + .map(Dynamic::from_float) + .map_err(|_| matrix_error(format!("Invalid numeric literal `{token}`"))) + } + + pub(super) fn ensure_numeric_matrix(matrix: &Array) -> Result<(), Box> { + if matrix.is_empty() { + return Err(matrix_error("mat expects at least one row")); + } + + let mut cols = None; + for row in matrix { + let row = row + .clone() + .into_array() + .map_err(|_| matrix_error("mat expects nested row arrays"))?; + + match cols { + Some(expected) if row.len() != expected => { + return Err(matrix_error("Matrix rows must have equal length")); + } + None => cols = Some(row.len()), + _ => {} + } + + ensure_numeric_list(&row, "mat")?; + } + + Ok(()) + } + + fn ensure_numeric_list(values: &Array, constructor: &str) -> Result<(), Box> { + if values.iter().all(is_numeric_scalar) { + Ok(()) + } else { + Err(matrix_error(format!( + "{constructor} expects INT or FLOAT values" + ))) + } + } + + fn is_numeric_scalar(value: &Dynamic) -> bool { + value.is_int() || value.is_float() + } + + fn matrix_error(message: impl Into) -> Box { + EvalAltResult::ErrorArithmetic(message.into(), Position::NONE).into() + } +} + #[export_module] pub mod matrix_functions { - #[cfg(feature = "nalgebra")] + use super::matrix_conventions::{ + ensure_numeric_matrix, parse_matrix_literal, vector_data_from_array, + }; use crate::matrix::{RhaiMatrix, RhaiVector}; use crate::validation_functions::{is_column_vector, is_row_vector}; use crate::{ @@ -13,9 +140,142 @@ pub mod matrix_functions { use crate::{if_matrices_and_compatible_convert_to_vec_array_and_do, FOIL}; #[cfg(feature = "nalgebra")] use nalgebralib::DMatrix; - use rhai::{Array, Dynamic, EvalAltResult, Map, Position, FLOAT, INT}; + use rhai::{Array, Dynamic, EvalAltResult, ImmutableString, Map, Position, FLOAT, INT}; use std::collections::BTreeMap; + /// Create a column vector from a numeric list. This is the default vector convention. + /// ```typescript + /// let v = vec([1, 2, 3]); + /// assert_eq(v, [[1], [2], [3]]); + /// ``` + /// ```typescript + /// let v = vec("1 2 3"); + /// assert_eq(v, [[1], [2], [3]]); + /// ``` + #[rhai_fn(name = "vec", return_raw)] + pub fn vec_from_array(values: Array) -> Result> { + col_from_array(values) + } + + /// Create a column vector from a compact numeric literal string. + #[rhai_fn(name = "vec", return_raw)] + pub fn vec_from_string(values: ImmutableString) -> Result> { + col_from_string(values) + } + + /// Create a row vector from a numeric list. + /// ```typescript + /// let r = row([1, 2, 3]); + /// assert_eq(r, [[1, 2, 3]]); + /// ``` + /// ```typescript + /// let r = row("1 2 3"); + /// assert_eq(r, [[1, 2, 3]]); + /// ``` + #[rhai_fn(name = "row", return_raw)] + pub fn row_from_array(values: Array) -> Result> { + Ok(RhaiMatrix::row_vector(vector_data_from_array(values, "row")?).to_array()) + } + + /// Create a row vector from a compact numeric literal string. + #[rhai_fn(name = "row", name = "R", return_raw)] + pub fn row_from_string(values: ImmutableString) -> Result> { + row_from_array(parse_matrix_literal(values)?) + } + + /// Create a column vector from a numeric list. + /// ```typescript + /// let c = col([1, 2, 3]); + /// assert_eq(c, [[1], [2], [3]]); + /// ``` + /// ```typescript + /// let c = col("1; 2; 3"); + /// assert_eq(c, [[1], [2], [3]]); + /// ``` + #[rhai_fn(name = "col", return_raw)] + pub fn col_from_array(values: Array) -> Result> { + Ok(RhaiMatrix::column_vector(vector_data_from_array(values, "col")?).to_array()) + } + + /// Create a column vector from a compact numeric literal string. + #[rhai_fn(name = "col", name = "C", return_raw)] + pub fn col_from_string(values: ImmutableString) -> Result> { + col_from_array(parse_matrix_literal(values)?) + } + + /// Validate and return a numeric matrix represented as nested row arrays. + /// ```typescript + /// let A = mat([[1, 2], [3, 4]]); + /// assert_eq(A, [[1, 2], [3, 4]]); + /// ``` + /// ```typescript + /// let A = mat("1 2; 3 4"); + /// assert_eq(A, [[1, 2], [3, 4]]); + /// ``` + #[rhai_fn(name = "mat", return_raw)] + pub fn mat_from_array(matrix: Array) -> Result> { + ensure_numeric_matrix(&matrix)?; + Ok(matrix) + } + + /// Create a numeric matrix from a compact literal string. + #[rhai_fn(name = "mat", name = "M", return_raw)] + pub fn mat_from_string(matrix: ImmutableString) -> Result> { + parse_matrix_literal(matrix) + } + + /// Short alias for [`transpose`]. + /// ```typescript + /// let c = T(row([1, 2, 3])); + /// assert_eq(c, [[1.0], [2.0], [3.0]]); + /// ``` + #[cfg(feature = "nalgebra")] + #[rhai_fn(name = "T", return_raw)] + pub fn transpose_alias(matrix: Array) -> Result> { + transpose_from_array(matrix) + } + + /// Short alias for [`mtimes`]. + /// ```typescript + /// let A = mat("1 2; 3 4"); + /// let x = col([5, 6]); + /// assert_eq(dot(A, x), [[17.0], [39.0]]); + /// ``` + /// ```typescript + /// let A = mat("1 2; 3 4"); + /// let x = col([5, 6]); + /// assert_eq(A.dot(x), [[17.0], [39.0]]); + /// ``` + #[cfg(feature = "nalgebra")] + #[rhai_fn(name = "dot", return_raw)] + pub fn dot(matrix1: Array, matrix2: Array) -> Result> { + mtimes(matrix1, matrix2) + } + + /// Short alias for [`horzcat`]. + /// ```typescript + /// let A = mat("1 2; 3 4"); + /// let x = col([5, 6]); + /// assert_eq(hcat(A, x), [[1.0, 2.0, 5.0], [3.0, 4.0, 6.0]]); + /// ``` + #[cfg(feature = "nalgebra")] + #[rhai_fn(name = "hcat", return_raw)] + pub fn hcat(matrix1: Array, matrix2: Array) -> Result> { + horzcat_from_array(matrix1, matrix2) + } + + /// Short alias for [`vertcat`]. + /// ```typescript + /// let A = mat("1 2; 3 4"); + /// let y = row([5, 6]); + /// assert_eq(vcat(A, y), [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]); + /// ``` + #[cfg(feature = "nalgebra")] + #[rhai_fn(name = "vcat", return_raw)] + pub fn vcat(matrix1: Array, matrix2: Array) -> Result> { + vertcat_from_array(matrix1, matrix2) + } + /// Calculates the inverse of a matrix. Fails if the matrix if not invertible, or if the /// elements of the matrix aren't FLOAT or INT. /// ```typescript diff --git a/tests/matrix_conventions.rs b/tests/matrix_conventions.rs new file mode 100644 index 0000000..c45c70f --- /dev/null +++ b/tests/matrix_conventions.rs @@ -0,0 +1,110 @@ +use rhai::{packages::Package, Array, Engine, EvalAltResult}; +use rhai_sci::SciPackage; + +#[test] +fn constructors_make_vector_orientation_explicit() { + assert_matrix_eq(eval_array("row([1, 2, 3])").unwrap(), &[&[1.0, 2.0, 3.0]]); + assert_matrix_eq( + eval_array("col([1, 2, 3])").unwrap(), + &[&[1.0], &[2.0], &[3.0]], + ); + assert_matrix_eq( + eval_array("vec([1, 2, 3])").unwrap(), + &[&[1.0], &[2.0], &[3.0]], + ); +} + +#[test] +fn string_literals_accept_spaces_commas_and_semicolons() { + assert_matrix_eq( + eval_array("mat(\"1, 2; 3, 4\")").unwrap(), + &[&[1.0, 2.0], &[3.0, 4.0]], + ); + assert_matrix_eq( + eval_array("M(\"1 2; 3 4\")").unwrap(), + &[&[1.0, 2.0], &[3.0, 4.0]], + ); + assert_matrix_eq(eval_array("R(\"1 2 3\")").unwrap(), &[&[1.0, 2.0, 3.0]]); + assert_matrix_eq( + eval_array("C(\"1; 2; 3\")").unwrap(), + &[&[1.0], &[2.0], &[3.0]], + ); +} + +#[test] +fn aliases_read_like_linear_algebra() { + let product = eval_array( + r#" + let A = mat("1 2; 3 4"); + let x = col([5, 6]); + dot(A, x) + "#, + ) + .unwrap(); + assert_matrix_eq(product, &[&[17.0], &[39.0]]); + + let method_product = eval_array( + r#" + let A = mat("1 2; 3 4"); + let x = col([5, 6]); + A.dot(x) + "#, + ) + .unwrap(); + assert_matrix_eq(method_product, &[&[17.0], &[39.0]]); + + assert_matrix_eq(eval_array("T(row([1, 2]))").unwrap(), &[&[1.0], &[2.0]]); + assert_matrix_eq( + eval_array("hcat(mat(\"1 2; 3 4\"), col([5, 6]))").unwrap(), + &[&[1.0, 2.0, 5.0], &[3.0, 4.0, 6.0]], + ); + assert_matrix_eq( + eval_array("vcat(mat(\"1 2; 3 4\"), row([5, 6]))").unwrap(), + &[&[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0]], + ); +} + +#[test] +fn matrix_constructor_rejects_ragged_literals() { + let err = eval_array("mat(\"1 2; 3\")").unwrap_err(); + match err.as_ref() { + EvalAltResult::ErrorArithmetic(message, _) => { + assert!(message.contains("equal length")); + } + other => panic!("unexpected error: {other:?}"), + } +} + +fn eval_array(script: &str) -> Result> { + let mut engine = Engine::new(); + engine.register_global_module(SciPackage::new().as_shared_module()); + engine.eval::(script) +} + +fn assert_matrix_eq(actual: Array, expected: &[&[f64]]) { + let actual = numeric_matrix(actual); + let expected = expected + .iter() + .map(|row| row.to_vec()) + .collect::>>(); + assert_eq!(actual, expected); +} + +fn numeric_matrix(matrix: Array) -> Vec> { + matrix + .into_iter() + .map(|row| { + row.into_array() + .expect("matrix rows should be arrays") + .into_iter() + .map(|value| { + if value.is_float() { + value.as_float().expect("value should be FLOAT") + } else { + value.as_int().expect("value should be INT") as f64 + } + }) + .collect() + }) + .collect() +} From 9a9f9a60528c8aa0f141cb6b811d76b57abb4b50 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Tue, 9 Jun 2026 20:35:49 -0400 Subject: [PATCH 18/22] Tighten matrix convention docs and validation tests --- src/matrices_and_arrays.rs | 16 ++++++++++++++++ tests/matrix_conventions.rs | 27 ++++++++++++++++++++------- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 772c30f..84162b9 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -158,6 +158,10 @@ pub mod matrix_functions { } /// Create a column vector from a compact numeric literal string. + /// ```typescript + /// let v = vec("1; 2; 3"); + /// assert_eq(v, [[1], [2], [3]]); + /// ``` #[rhai_fn(name = "vec", return_raw)] pub fn vec_from_string(values: ImmutableString) -> Result> { col_from_string(values) @@ -178,6 +182,10 @@ pub mod matrix_functions { } /// Create a row vector from a compact numeric literal string. + /// ```typescript + /// let r = R("1 2 3"); + /// assert_eq(r, [[1, 2, 3]]); + /// ``` #[rhai_fn(name = "row", name = "R", return_raw)] pub fn row_from_string(values: ImmutableString) -> Result> { row_from_array(parse_matrix_literal(values)?) @@ -198,6 +206,10 @@ pub mod matrix_functions { } /// Create a column vector from a compact numeric literal string. + /// ```typescript + /// let c = C("1; 2; 3"); + /// assert_eq(c, [[1], [2], [3]]); + /// ``` #[rhai_fn(name = "col", name = "C", return_raw)] pub fn col_from_string(values: ImmutableString) -> Result> { col_from_array(parse_matrix_literal(values)?) @@ -219,6 +231,10 @@ pub mod matrix_functions { } /// Create a numeric matrix from a compact literal string. + /// ```typescript + /// let A = M("1, 2; 3, 4"); + /// assert_eq(A, [[1, 2], [3, 4]]); + /// ``` #[rhai_fn(name = "mat", name = "M", return_raw)] pub fn mat_from_string(matrix: ImmutableString) -> Result> { parse_matrix_literal(matrix) diff --git a/tests/matrix_conventions.rs b/tests/matrix_conventions.rs index c45c70f..18d6c89 100644 --- a/tests/matrix_conventions.rs +++ b/tests/matrix_conventions.rs @@ -66,13 +66,13 @@ fn aliases_read_like_linear_algebra() { #[test] fn matrix_constructor_rejects_ragged_literals() { - let err = eval_array("mat(\"1 2; 3\")").unwrap_err(); - match err.as_ref() { - EvalAltResult::ErrorArithmetic(message, _) => { - assert!(message.contains("equal length")); - } - other => panic!("unexpected error: {other:?}"), - } + assert_error_contains("mat(\"1 2; 3\")", "equal length"); +} + +#[test] +fn matrix_constructor_rejects_invalid_arrays() { + assert_error_contains("mat([[1, 2], [3]])", "equal length"); + assert_error_contains("mat([[1, \"x\"]])", "INT or FLOAT"); } fn eval_array(script: &str) -> Result> { @@ -81,6 +81,19 @@ fn eval_array(script: &str) -> Result> { engine.eval::(script) } +fn assert_error_contains(script: &str, expected: &str) { + let err = eval_array(script).unwrap_err(); + match err.as_ref() { + EvalAltResult::ErrorArithmetic(message, _) => { + assert!( + message.contains(expected), + "expected error message `{message}` to contain `{expected}`" + ); + } + other => panic!("unexpected error: {other:?}"), + } +} + fn assert_matrix_eq(actual: Array, expected: &[&[f64]]) { let actual = numeric_matrix(actual); let expected = expected From 798f21242122aedb8adef85488f5a9d248cd5738 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Wed, 10 Jun 2026 08:16:58 -0400 Subject: [PATCH 19/22] Reject empty matrix convention inputs --- src/matrices_and_arrays.rs | 12 ++++++++++++ tests/matrix_conventions.rs | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 84162b9..08ea715 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -7,6 +7,10 @@ mod matrix_conventions { values: Array, constructor: &str, ) -> Result> { + if values.is_empty() { + return Err(empty_values_error(constructor)); + } + if values.iter().all(is_numeric_scalar) { return Ok(values); } @@ -107,6 +111,10 @@ mod matrix_conventions { } fn ensure_numeric_list(values: &Array, constructor: &str) -> Result<(), Box> { + if values.is_empty() { + return Err(empty_values_error(constructor)); + } + if values.iter().all(is_numeric_scalar) { Ok(()) } else { @@ -120,6 +128,10 @@ mod matrix_conventions { value.is_int() || value.is_float() } + fn empty_values_error(constructor: &str) -> Box { + matrix_error(format!("{constructor} expects at least one value")) + } + fn matrix_error(message: impl Into) -> Box { EvalAltResult::ErrorArithmetic(message.into(), Position::NONE).into() } diff --git a/tests/matrix_conventions.rs b/tests/matrix_conventions.rs index 18d6c89..3ec4a78 100644 --- a/tests/matrix_conventions.rs +++ b/tests/matrix_conventions.rs @@ -73,6 +73,14 @@ fn matrix_constructor_rejects_ragged_literals() { fn matrix_constructor_rejects_invalid_arrays() { assert_error_contains("mat([[1, 2], [3]])", "equal length"); assert_error_contains("mat([[1, \"x\"]])", "INT or FLOAT"); + assert_error_contains("mat([[]])", "at least one value"); +} + +#[test] +fn vector_constructors_reject_empty_arrays() { + assert_error_contains("vec([])", "at least one value"); + assert_error_contains("row([])", "at least one value"); + assert_error_contains("col([])", "at least one value"); } fn eval_array(script: &str) -> Result> { From 5db010b90bcc1db2202e8049521f8477eea0120c Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Wed, 10 Jun 2026 08:25:51 -0400 Subject: [PATCH 20/22] Add neural network backprop example --- README.md | 1 + examples/neural_network_backprop.rhai | 199 +++++++++++++++++++++++ examples/neural_network_backprop.rs | 17 ++ tests/neural_network_backprop_example.rs | 30 ++++ 4 files changed, 247 insertions(+) create mode 100644 examples/neural_network_backprop.rhai create mode 100644 examples/neural_network_backprop.rs create mode 100644 tests/neural_network_backprop_example.rs diff --git a/README.md b/README.md index 224df6d..5c0a3d1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ See the `examples` directory for more: - `matrix_inversion.rhai` demonstrates matrix inversion. - `download_and_regress.rhai` fetches data and performs linear regression. - `projectile_motion.rhai` uses trigonometry and array utilities to simulate a projectile trajectory. +- `neural_network_backprop.rhai` trains a tiny neural network on XOR with explicit backpropagation. ## Matrix and Vector Conventions diff --git a/examples/neural_network_backprop.rhai b/examples/neural_network_backprop.rhai new file mode 100644 index 0000000..11f191b --- /dev/null +++ b/examples/neural_network_backprop.rhai @@ -0,0 +1,199 @@ +// Train a tiny 2-2-1 neural network on XOR with explicit backpropagation. + +fn sigmoid(x) { + 1.0 / (1.0 + exp(0.0 - x)) +} + +fn sigmoid_matrix(A) { + let out = []; + + for row in A { + let out_row = []; + for value in row { + out_row.push(sigmoid(value)); + } + out.push(out_row); + } + + out +} + +fn sigmoid_prime_from_activation(A) { + let out = []; + + for row in A { + let out_row = []; + for value in row { + out_row.push(value * (1.0 - value)); + } + out.push(out_row); + } + + out +} + +fn sub_matrix(A, B) { + let out = []; + + for i in 0..A.len() { + let a_row = A[i]; + let b_row = B[i]; + let row = []; + for j in 0..a_row.len() { + row.push(a_row[j] - b_row[j]); + } + out.push(row); + } + + out +} + +fn scale_matrix(A, scale) { + let out = []; + + for row in A { + let out_row = []; + for value in row { + out_row.push(value * scale); + } + out.push(out_row); + } + + out +} + +fn hadamard(A, B) { + let out = []; + + for i in 0..A.len() { + let a_row = A[i]; + let b_row = B[i]; + let row = []; + for j in 0..a_row.len() { + row.push(a_row[j] * b_row[j]); + } + out.push(row); + } + + out +} + +fn column_to_row(A) { + let values = []; + + for row in A { + values.push(row[0]); + } + + row(values) +} + +fn sum_squares(A) { + let total = 0.0; + + for row in A { + for value in row { + total += value * value; + } + } + + total +} + +fn forward(W1, W2, x) { + let x_with_bias = vcat(x, col([1.0])); + let hidden = sigmoid_matrix(dot(W1, x_with_bias)); + let hidden_with_bias = vcat(hidden, col([1.0])); + let output = sigmoid_matrix(dot(W2, hidden_with_bias)); + + #{ + "x_with_bias": x_with_bias, + "hidden": hidden, + "hidden_with_bias": hidden_with_bias, + "output": output + } +} + +fn total_loss(W1, W2, inputs, targets) { + let loss = 0.0; + + for i in 0..inputs.len() { + let x = inputs[i]; + let target_value = targets[i]; + let step = forward(W1, W2, x); + let prediction = step.output; + let target = col([target_value]); + let residual = sub_matrix(prediction, target); + loss += 0.5 * sum_squares(residual); + } + + loss +} + +fn predictions(W1, W2, inputs) { + let out = []; + + for x in inputs { + let prediction = forward(W1, W2, x).output; + let row = prediction[0]; + out.push(row[0]); + } + + out +} + +let inputs = [ + col([0.0, 0.0]), + col([0.0, 1.0]), + col([1.0, 0.0]), + col([1.0, 1.0]) +]; +let targets = [0.0, 1.0, 1.0, 0.0]; + +// Fixed starting weights keep the example deterministic while still learning. +let W1 = M("0.6888 0.5159 -0.1589; -0.4822 0.0225 -0.1901"); +let W2 = M("0.5676 -0.3934 -0.0468"); +let learning_rate = 1.5; +let epochs = 800; +let initial_loss = total_loss(W1, W2, inputs, targets); + +for epoch in 0..epochs { + for i in 0..inputs.len() { + let x = inputs[i]; + let target_value = targets[i]; + let step = forward(W1, W2, x); + let target = col([target_value]); + + let output_error = sub_matrix(step.output, target); + let output_slope = sigmoid_prime_from_activation(step.output); + let output_delta = hadamard(output_error, output_slope); + let output_weight_row = W2[0]; + let output_weights_without_bias = T(row([output_weight_row[0], output_weight_row[1]])); + + let hidden_error = dot(output_weights_without_bias, output_delta); + let hidden_slope = sigmoid_prime_from_activation(step.hidden); + let hidden_delta = hadamard(hidden_error, hidden_slope); + + let hidden_with_bias_row = column_to_row(step.hidden_with_bias); + let x_with_bias_row = column_to_row(step.x_with_bias); + let grad_W2 = dot(output_delta, hidden_with_bias_row); + let grad_W1 = dot(hidden_delta, x_with_bias_row); + + W2 = sub_matrix(W2, scale_matrix(grad_W2, learning_rate)); + W1 = sub_matrix(W1, scale_matrix(grad_W1, learning_rate)); + } +} + +let final_loss = total_loss(W1, W2, inputs, targets); +let final_predictions = predictions(W1, W2, inputs); + +let result = #{ + "initial_loss": initial_loss, + "final_loss": final_loss, + "predictions": final_predictions, + "W1": W1, + "W2": W2 +}; + +print(result); +result diff --git a/examples/neural_network_backprop.rs b/examples/neural_network_backprop.rs new file mode 100644 index 0000000..6045d45 --- /dev/null +++ b/examples/neural_network_backprop.rs @@ -0,0 +1,17 @@ +//! Trains a tiny neural network with backpropagation using rhai-sci matrices. + +fn main() { + #[cfg(feature = "nalgebra")] + { + use rhai::{packages::Package, Engine, Map}; + use rhai_sci::SciPackage; + + let mut engine = Engine::new(); + engine.register_global_module(SciPackage::new().as_shared_module()); + + let result: Map = engine + .eval_file("examples/neural_network_backprop.rhai".into()) + .expect("script should run"); + println!("{result:?}"); + } +} diff --git a/tests/neural_network_backprop_example.rs b/tests/neural_network_backprop_example.rs new file mode 100644 index 0000000..82e2dfa --- /dev/null +++ b/tests/neural_network_backprop_example.rs @@ -0,0 +1,30 @@ +#![cfg(feature = "nalgebra")] + +use rhai::{packages::Package, Array, Engine, Map}; +use rhai_sci::SciPackage; + +#[test] +fn neural_network_backprop_example_learns_xor() { + let mut engine = Engine::new(); + engine.register_global_module(SciPackage::new().as_shared_module()); + + let result: Map = engine + .eval_file("examples/neural_network_backprop.rhai".into()) + .expect("script evaluation should succeed"); + + let initial_loss = result["initial_loss"].clone().cast::(); + let final_loss = result["final_loss"].clone().cast::(); + let predictions = result["predictions"].clone().cast::(); + let predictions = predictions + .into_iter() + .map(|value| value.cast::()) + .collect::>(); + + assert!(initial_loss > 0.45); + assert!(final_loss < 0.02); + assert!(final_loss < initial_loss); + assert!(predictions[0] < 0.15); + assert!(predictions[1] > 0.85); + assert!(predictions[2] > 0.85); + assert!(predictions[3] < 0.15); +} From 525de5f4cfeb46deedcb7a243a11cff0fd54c37b Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Mon, 15 Jun 2026 16:32:50 -0400 Subject: [PATCH 21/22] Fix transpose orientation for column vectors --- examples/neural_network_backprop.rhai | 16 ++-------------- src/matrices_and_arrays.rs | 15 ++++++++++----- tests/matrix_conventions.rs | 5 +++++ tests/matrix_ops.rs | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/examples/neural_network_backprop.rhai b/examples/neural_network_backprop.rhai index 11f191b..e382826 100644 --- a/examples/neural_network_backprop.rhai +++ b/examples/neural_network_backprop.rhai @@ -78,16 +78,6 @@ fn hadamard(A, B) { out } -fn column_to_row(A) { - let values = []; - - for row in A { - values.push(row[0]); - } - - row(values) -} - fn sum_squares(A) { let total = 0.0; @@ -174,10 +164,8 @@ for epoch in 0..epochs { let hidden_slope = sigmoid_prime_from_activation(step.hidden); let hidden_delta = hadamard(hidden_error, hidden_slope); - let hidden_with_bias_row = column_to_row(step.hidden_with_bias); - let x_with_bias_row = column_to_row(step.x_with_bias); - let grad_W2 = dot(output_delta, hidden_with_bias_row); - let grad_W1 = dot(hidden_delta, x_with_bias_row); + let grad_W2 = dot(output_delta, T(step.hidden_with_bias)); + let grad_W1 = dot(hidden_delta, T(step.x_with_bias)); W2 = sub_matrix(W2, scale_matrix(grad_W2, learning_rate)); W1 = sub_matrix(W1, scale_matrix(grad_W1, learning_rate)); diff --git a/src/matrices_and_arrays.rs b/src/matrices_and_arrays.rs index 08ea715..3d7a88d 100644 --- a/src/matrices_and_arrays.rs +++ b/src/matrices_and_arrays.rs @@ -257,6 +257,10 @@ pub mod matrix_functions { /// let c = T(row([1, 2, 3])); /// assert_eq(c, [[1.0], [2.0], [3.0]]); /// ``` + /// ```typescript + /// let r = T(vec([1, 2, 3])); + /// assert_eq(r, [[1.0, 2.0, 3.0]]); + /// ``` #[cfg(feature = "nalgebra")] #[rhai_fn(name = "T", return_raw)] pub fn transpose_alias(matrix: Array) -> Result> { @@ -540,11 +544,12 @@ pub mod matrix_functions { /// ``` #[rhai_fn(name = "transpose", return_raw)] pub fn transpose(matrix: RhaiMatrix) -> Result> { - let oriented = matrix - .as_row() - .or_else(|| matrix.as_column()) - .unwrap_or(matrix); - oriented.transpose() + let mut raw = matrix.clone().to_array(); + if !raw.is_empty() && matrix_size_by_reference(&mut raw).len() == 1 { + return RhaiMatrix::row_vector(raw).transpose(); + } + + matrix.transpose() } /// Transpose an array by first converting it to a [`RhaiMatrix`]. diff --git a/tests/matrix_conventions.rs b/tests/matrix_conventions.rs index 3ec4a78..5a44498 100644 --- a/tests/matrix_conventions.rs +++ b/tests/matrix_conventions.rs @@ -54,6 +54,11 @@ fn aliases_read_like_linear_algebra() { assert_matrix_eq(method_product, &[&[17.0], &[39.0]]); assert_matrix_eq(eval_array("T(row([1, 2]))").unwrap(), &[&[1.0], &[2.0]]); + assert_matrix_eq(eval_array("T(vec([1, 2]))").unwrap(), &[&[1.0, 2.0]]); + assert_matrix_eq( + eval_array("dot(T(vec([1, 2])), vec([3, 4]))").unwrap(), + &[&[11.0]], + ); assert_matrix_eq( eval_array("hcat(mat(\"1 2; 3 4\"), col([5, 6]))").unwrap(), &[&[1.0, 2.0, 5.0], &[3.0, 4.0, 6.0]], diff --git a/tests/matrix_ops.rs b/tests/matrix_ops.rs index e769f8b..8cb0b16 100644 --- a/tests/matrix_ops.rs +++ b/tests/matrix_ops.rs @@ -17,6 +17,22 @@ fn transpose_orients_row_vector() { assert!(is_column_vector(&mut result)); } +#[test] +fn transpose_orients_column_vector() { + let data: Array = vec![ + Dynamic::from_int(1), + Dynamic::from_int(2), + Dynamic::from_int(3), + ]; + let column = RhaiMatrix::column_vector(data); + let mut result = transpose(column).unwrap().to_array(); + assert!(is_row_vector(&mut result)); + + let row = result[0].clone().into_array().unwrap(); + let values: Vec = row.into_iter().map(|d| d.as_float().unwrap()).collect(); + assert_eq!(values, vec![1.0, 2.0, 3.0]); +} + #[test] fn horzcat_concatenates_rows() { let a: Array = vec![Dynamic::from_int(1), Dynamic::from_int(2)]; From 5a7072278601531c35c2091ce8647d2ff5800cd1 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Mon, 15 Jun 2026 16:38:39 -0400 Subject: [PATCH 22/22] Update rhai-sci version in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c0a3d1..1392cf9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Add the crate to your `Cargo.toml`: ```toml -rhai-sci = "0.2.3" +rhai-sci = "0.3.0" ``` Evaluate a single line of Rhai code: