From 238677f8bbca2f79b83712ccd645e31d2f806144 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Tue, 25 Aug 2026 10:39:54 +0100 Subject: [PATCH 1/4] fix: fuse StandardScaler::transform to single-allocation pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-column take_column/sub_scalar/div_scalar/build_matrix_from_columns pattern with a single M::fill + element-wise (x - mean) / std loop. Eliminates O(d) medium Vec allocations and several full-matrix temporaries that caused ~9500x wall-time regression and RSS inflation on large matrices (4000x4000: 85.9s → 0.009s per issue #449). Remove now-dead build_matrix_from_columns helper and its test. Add comprehensive test covering all parameter combinations (with_mean, with_std, zero-variance columns, column-count mismatch) verified against numpy. --- Cargo.toml | 2 +- src/preprocessing/numerical.rs | 190 +++++++++++++++++++++++---------- 2 files changed, 137 insertions(+), 55 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bdbb9135..12ca418c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "smartcore" description = "Machine Learning in Rust." homepage = "https://smartcorelib.github.io/" -version = "0.6.12" +version = "0.6.13" authors = ["smartcore Developers"] edition = "2024" rust-version = "1.85" diff --git a/src/preprocessing/numerical.rs b/src/preprocessing/numerical.rs index 148a2a14..e6378096 100644 --- a/src/preprocessing/numerical.rs +++ b/src/preprocessing/numerical.rs @@ -138,76 +138,36 @@ impl> UnsupervisedEstimator> Transformer for StandardScaler { fn transform(&self, x: &M) -> Result { - let (_, n_cols) = x.shape(); - if n_cols != self.means.len() { + let (nrows, ncols) = x.shape(); + if ncols != self.means.len() { return Err(Failed::because( FailedError::TransformFailed, &format!( "Expected {} columns, but got {} columns instead.", self.means.len(), - n_cols, + ncols, ), )); } - Ok(build_matrix_from_columns( - self.means - .iter() - .zip(self.stds.iter()) - .enumerate() - .map(|(column_index, (column_mean, column_std))| { - x.take_column(column_index) - .sub_scalar(T::from(self.adjust_column_mean(*column_mean)).unwrap()) - .div_scalar(T::from(self.adjust_column_std(*column_std)).unwrap()) - }) - .collect(), - ) - .unwrap()) + let mut output = M::fill(nrows, ncols, T::zero()); + for j in 0..ncols { + let mean = T::from(self.adjust_column_mean(self.means[j])).unwrap(); + let std = T::from(self.adjust_column_std(self.stds[j])).unwrap(); + for i in 0..nrows { + let val = *x.get((i, j)); + output.set((i, j), (val - mean) / std); + } + } + Ok(output) } } -/// From a collection of matrices, that contain columns, construct -/// a matrix by stacking the columns horizontally. -fn build_matrix_from_columns(columns: Vec) -> Option -where - T: Number + RealNumber, - M: Array2, -{ - columns.first().cloned().map(|output_matrix| { - columns - .iter() - .skip(1) - .fold(output_matrix, |current_matrix, new_colum| { - current_matrix.h_stack(new_colum) - }) - }) -} - #[cfg(test)] mod tests { mod helper_functionality { - use super::super::{build_matrix_from_columns, ensure_std_valid}; - use crate::linalg::basic::matrix::DenseMatrix; - - #[test] - fn combine_three_columns() { - assert_eq!( - build_matrix_from_columns(vec![ - DenseMatrix::from_2d_vec(&vec![vec![1.0], vec![1.0], vec![1.0],]).unwrap(), - DenseMatrix::from_2d_vec(&vec![vec![2.0], vec![2.0], vec![2.0],]).unwrap(), - DenseMatrix::from_2d_vec(&vec![vec![3.0], vec![3.0], vec![3.0],]).unwrap() - ]), - Some( - DenseMatrix::from_2d_vec(&vec![ - vec![1.0, 2.0, 3.0], - vec![1.0, 2.0, 3.0], - vec![1.0, 2.0, 3.0] - ]) - .unwrap() - ) - ) - } + use super::super::ensure_std_valid; #[test] fn negative_value_should_be_replace_with_minimal_positive_value() { @@ -426,6 +386,128 @@ mod tests { ) } + /// Verify transform correctness across all parameter combinations. + #[test] + fn transform_all_parameter_combinations() { + let data = DenseMatrix::from_2d_vec(&vec![ + vec![1.0, 10.0, 100.0], + vec![2.0, 20.0, 200.0], + vec![3.0, 30.0, 300.0], + vec![4.0, 40.0, 400.0], + ]) + .unwrap(); + + // Default: with_mean=true, with_std=true + // std = population std: sqrt(mean of squared deviations) + // For [1,2,3,4]: mean=2.5, pop_std = sqrt(5/4) ≈ 1.1180339887 + let scaler = StandardScaler::fit(&data, StandardScalerParameters::default()).unwrap(); + let result = scaler.transform(&data).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![ + -1.3416407864998738, + -1.3416407864998738, + -1.3416407864998738, + ], + vec![ + -0.4472135954999579, + -0.4472135954999579, + -0.4472135954999579, + ], + vec![0.4472135954999579, 0.4472135954999579, 0.4472135954999579], + vec![1.3416407864998738, 1.3416407864998738, 1.3416407864998738], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "Default transform failed:\n{result}\nexpected:\n{expected}" + ); + + // with_mean=true, with_std=false + let scaler = StandardScaler::fit( + &data, + StandardScalerParameters { + with_mean: true, + with_std: false, + }, + ) + .unwrap(); + let result = scaler.transform(&data).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![-1.5, -15.0, -150.0], + vec![-0.5, -5.0, -50.0], + vec![0.5, 5.0, 50.0], + vec![1.5, 15.0, 150.0], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "with_mean=true, with_std=false transform failed:\n{result}\nexpected:\n{expected}" + ); + + // with_mean=false, with_std=true: (x - 0) / std = x / std + let scaler = StandardScaler::fit( + &data, + StandardScalerParameters { + with_mean: false, + with_std: true, + }, + ) + .unwrap(); + let result = scaler.transform(&data).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![0.8944271909999159, 0.8944271909999159, 0.8944271909999159], + vec![1.7888543819998317, 1.7888543819998317, 1.7888543819998317], + vec![2.6832815729997477, 2.6832815729997477, 2.6832815729997477], + vec![3.5777087639996634, 3.5777087639996634, 3.5777087639996634], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "with_mean=false, with_std=true transform failed:\n{result}\nexpected:\n{expected}" + ); + + // with_mean=false, with_std=false (passthrough) + let scaler = StandardScaler::fit( + &data, + StandardScalerParameters { + with_mean: false, + with_std: false, + }, + ) + .unwrap(); + let result = scaler.transform(&data).unwrap(); + assert!( + result.approximate_eq(&data, 1e-10), + "with_mean=false, with_std=false should return data unchanged:\n{result}\nexpected:\n{data}" + ); + + // Zero-variance column mixed with normal columns + let mixed = DenseMatrix::from_2d_vec(&vec![ + vec![1.0, 5.0], + vec![2.0, 5.0], + vec![3.0, 5.0], + vec![4.0, 5.0], + ]) + .unwrap(); + let scaler = StandardScaler::fit(&mixed, StandardScalerParameters::default()).unwrap(); + let result = scaler.transform(&mixed).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![-1.3416407864998738, 0.0], + vec![-0.4472135954999579, 0.0], + vec![0.4472135954999579, 0.0], + vec![1.3416407864998738, 0.0], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "Zero-variance mixed column transform failed:\n{result}\nexpected:\n{expected}" + ); + + // Column count mismatch returns error: scaler expects 2 cols, data has 1 + let narrow = DenseMatrix::from_2d_vec(&vec![vec![1.0]]).unwrap(); + assert!(scaler.transform(&narrow).is_err()); + } + /// Same as `fit_for_random_values` test, but using a `StandardScaler` that has been /// serialized and deserialized. #[cfg_attr( From 987107dae9ef9ad027fab52fc74f6776f4a26ee2 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Tue, 25 Aug 2026 10:56:06 +0100 Subject: [PATCH 2/4] fix(xgboost): harden empty-data guard per #448 review - Extend guard to also reject zero-feature matrices (n_features == 0) - Improve error message to 'Training data must contain at least one sample and one feature.' - Add test_fit_on_zero_features_returns_error - Add comment on scaffold matrix in test_fit_on_empty_data_returns_error --- src/xgboost/xgb_regressor.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/xgboost/xgb_regressor.rs b/src/xgboost/xgb_regressor.rs index 3f97ee73..e71c83db 100644 --- a/src/xgboost/xgb_regressor.rs +++ b/src/xgboost/xgb_regressor.rs @@ -534,11 +534,11 @@ impl, Y: Array1> XGRegres )); } - let (n_samples, _) = data.shape(); - if n_samples == 0 { + let (n_samples, n_features) = data.shape(); + if n_samples == 0 || n_features == 0 { return Err(Failed::because( FailedError::ParametersError, - "Training data must have at least one row.", + "Training data must contain at least one sample and one feature.", )); } @@ -826,6 +826,7 @@ mod tests { #[test] fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 1.0], vec![2.0, 1.0]]).unwrap(); let empty = full.take(&[] as &[usize], 0); assert_eq!(empty.shape(), (0, 2)); @@ -836,6 +837,18 @@ mod tests { assert_eq!(model.err().unwrap().error(), FailedError::ParametersError); } + #[test] + fn test_fit_on_zero_features_returns_error() { + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 1.0], vec![2.0, 1.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y = vec![1.0, 2.0]; + let model = XGRegressor::fit(&no_features, &y, XGRegressorParameters::default()); + assert!(model.is_err()); + assert_eq!(model.err().unwrap().error(), FailedError::ParametersError); + } + #[test] fn test_sample_without_replacement_clamps_to_one_row() { // (population, ratio): each pair gives `floor(population * ratio) == 0`. From efb1dbbf2041a4a6fd8c92a8d0d7725181dd2187 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Tue, 25 Aug 2026 11:09:03 +0100 Subject: [PATCH 3/4] fix(tree): guard all tree/ensemble fit methods against empty data Add n_samples == 0 || n_features == 0 guards to: - DecisionTreeClassifier::fit - BaseTreeRegressor::fit - RandomForestClassifier::fit - BaseForestRegressor::fit All return FailedError::ParametersError with message: 'Training data must contain at least one sample and one feature.' Previously these could panic (divide-by-zero, empty-range) or produce undefined models when called with zero-row or zero-column matrices. Regression tests added for each guarded path. --- src/ensemble/base_forest_regressor.rs | 34 +++++++++++++ src/ensemble/random_forest_classifier.rs | 33 +++++++++++++ src/tree/base_tree_regressor.rs | 61 +++++++++++++++++++++++- src/tree/decision_tree_classifier.rs | 21 +++++++- 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/ensemble/base_forest_regressor.rs b/src/ensemble/base_forest_regressor.rs index b6dbb59e..c11c3a21 100644 --- a/src/ensemble/base_forest_regressor.rs +++ b/src/ensemble/base_forest_regressor.rs @@ -89,6 +89,12 @@ impl, Y: Array1 if n_rows != y.shape() { return Err(Failed::fit("Number of rows in X should = len(y)")); } + if n_rows == 0 || num_attributes == 0 { + return Err(Failed::because( + FailedError::ParametersError, + "Training data must contain at least one sample and one feature.", + )); + } let mtry = parameters .m @@ -223,6 +229,7 @@ impl, Y: Array1 #[cfg(test)] mod tests { use super::*; + use crate::linalg::basic::arrays::Array; use crate::linalg::basic::matrix::DenseMatrix; #[test] @@ -244,4 +251,31 @@ mod tests { assert_eq!(regressor.trees.unwrap().len(), 5); assert!(regressor.samples.is_some()); } + + #[test] + fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 2.0], vec![3.0, 4.0]]).unwrap(); + let empty = full.take(&[] as &[usize], 0); + assert_eq!(empty.shape(), (0, 2)); + + let y: Vec = vec![]; + let result = BaseForestRegressor::fit( + &empty, + &y, + BaseForestRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 5, + m: None, + keep_samples: false, + seed: 0, + bootstrap: true, + splitter: crate::tree::base_tree_regressor::Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } } diff --git a/src/ensemble/random_forest_classifier.rs b/src/ensemble/random_forest_classifier.rs index 78d80631..676450d2 100644 --- a/src/ensemble/random_forest_classifier.rs +++ b/src/ensemble/random_forest_classifier.rs @@ -461,6 +461,12 @@ impl, Y: Array1 = vec![0; y_ncols]; let classes = y.unique(); @@ -619,6 +625,7 @@ impl, Y: Array1 = vec![]; + let result = RandomForestClassifier::fit( + &empty, + &y, + RandomForestClassifierParameters { + criterion: SplitCriterion::Gini, + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 10, + m: None, + keep_samples: false, + seed: 0, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test diff --git a/src/tree/base_tree_regressor.rs b/src/tree/base_tree_regressor.rs index 24229b06..fb03e01c 100644 --- a/src/tree/base_tree_regressor.rs +++ b/src/tree/base_tree_regressor.rs @@ -9,7 +9,7 @@ use rand::seq::SliceRandom; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::error::Failed; +use crate::error::{Failed, FailedError}; use crate::linalg::basic::arrays::{Array1, Array2, MutArrayView1}; use crate::numbers::basenum::Number; use crate::rand_custom::get_rng_impl; @@ -184,6 +184,12 @@ impl, Y: Array1> if x_nrows != y.shape() { return Err(Failed::fit("Size of x should equal size of y")); } + if x_nrows == 0 || num_attributes == 0 { + return Err(Failed::because( + FailedError::ParametersError, + "Training data must contain at least one sample and one feature.", + )); + } let samples = vec![1; x_nrows]; BaseTreeRegressor::fit_weak_learner(x, y, samples, num_attributes, parameters) @@ -541,3 +547,56 @@ impl, Y: Array1> true } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::linalg::basic::arrays::Array; + use crate::linalg::basic::matrix::DenseMatrix; + + #[test] + fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 2.0], vec![3.0, 4.0]]).unwrap(); + let empty = full.take(&[] as &[usize], 0); + assert_eq!(empty.shape(), (0, 2)); + + let y: Vec = vec![]; + let result = BaseTreeRegressor::fit( + &empty, + &y, + BaseTreeRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + seed: None, + splitter: Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + + #[test] + fn test_fit_on_zero_features_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the zero-feature case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 2.0], vec![3.0, 4.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y = vec![1.0_f64, 2.0]; + let result = BaseTreeRegressor::fit( + &no_features, + &y, + BaseTreeRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + seed: None, + splitter: Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } +} diff --git a/src/tree/decision_tree_classifier.rs b/src/tree/decision_tree_classifier.rs index 8953d31a..08020fd9 100644 --- a/src/tree/decision_tree_classifier.rs +++ b/src/tree/decision_tree_classifier.rs @@ -76,7 +76,7 @@ use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use crate::api::{Predictor, SupervisedEstimator}; -use crate::error::Failed; +use crate::error::{Failed, FailedError}; use crate::linalg::basic::arrays::MutArray; use crate::linalg::basic::arrays::{Array1, Array2, MutArrayView1}; use crate::linalg::basic::matrix::DenseMatrix; @@ -552,6 +552,12 @@ impl, Y: Array1> if x_nrows != y.shape() { return Err(Failed::fit("Size of x should equal size of y")); } + if x_nrows == 0 || num_attributes == 0 { + return Err(Failed::because( + FailedError::ParametersError, + "Training data must contain at least one sample and one feature.", + )); + } let samples = vec![1; x_nrows]; DecisionTreeClassifier::fit_weak_learner(x, y, samples, num_attributes, parameters) @@ -1116,6 +1122,19 @@ mod tests { assert!(fail.is_err()); } + #[test] + fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 1.0], vec![0.0, 1.0]]).unwrap(); + let empty = full.take(&[] as &[usize], 0); + assert_eq!(empty.shape(), (0, 2)); + + let y: Vec = vec![]; + let result = DecisionTreeClassifier::fit(&empty, &y, Default::default()); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test From 477141f6d963504cac1f0274b26dbce5a41ac161 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Tue, 25 Aug 2026 11:15:57 +0100 Subject: [PATCH 4/4] fix(preprocessing): row-major loop order in StandardScaler::transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap fused transform loop from column-outer/row-inner to row-outer/ col-inner with pre-computed (mean, std) Vec. DenseMatrix uses row-major layout, so the previous ordering caused strided reads and writes on large matrices. Also add zero-features regression tests for DecisionTreeClassifier and BaseForestRegressor to match BaseTreeRegressor coverage. Audit: ExtraTreesRegressor and RandomForestRegressor both delegate to BaseForestRegressor::fit which already has the guard — no changes needed. Addresses review feedback from Mec-iS on PR #450. --- src/ensemble/base_forest_regressor.rs | 27 +++++++++++++++++++++++++++ src/preprocessing/numerical.rs | 13 +++++++++---- src/tree/decision_tree_classifier.rs | 13 +++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/ensemble/base_forest_regressor.rs b/src/ensemble/base_forest_regressor.rs index c11c3a21..51eb2c41 100644 --- a/src/ensemble/base_forest_regressor.rs +++ b/src/ensemble/base_forest_regressor.rs @@ -278,4 +278,31 @@ mod tests { assert!(result.is_err()); assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); } + + #[test] + fn test_fit_on_zero_features_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the zero-feature case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 2.0], vec![3.0, 4.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y: Vec = vec![1.0, 2.0]; + let result = BaseForestRegressor::fit( + &no_features, + &y, + BaseForestRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 5, + m: None, + keep_samples: false, + seed: 0, + bootstrap: true, + splitter: crate::tree::base_tree_regressor::Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } } diff --git a/src/preprocessing/numerical.rs b/src/preprocessing/numerical.rs index e6378096..3c37e0c0 100644 --- a/src/preprocessing/numerical.rs +++ b/src/preprocessing/numerical.rs @@ -151,10 +151,15 @@ impl> Transformer for StandardScaler } let mut output = M::fill(nrows, ncols, T::zero()); - for j in 0..ncols { - let mean = T::from(self.adjust_column_mean(self.means[j])).unwrap(); - let std = T::from(self.adjust_column_std(self.stds[j])).unwrap(); - for i in 0..nrows { + let col_params: Vec<(T, T)> = (0..ncols) + .map(|j| { + let mean = T::from(self.adjust_column_mean(self.means[j])).unwrap(); + let std = T::from(self.adjust_column_std(self.stds[j])).unwrap(); + (mean, std) + }) + .collect(); + for i in 0..nrows { + for (j, &(mean, std)) in col_params.iter().enumerate() { let val = *x.get((i, j)); output.set((i, j), (val - mean) / std); } diff --git a/src/tree/decision_tree_classifier.rs b/src/tree/decision_tree_classifier.rs index 08020fd9..595063a8 100644 --- a/src/tree/decision_tree_classifier.rs +++ b/src/tree/decision_tree_classifier.rs @@ -1135,6 +1135,19 @@ mod tests { assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); } + #[test] + fn test_fit_on_zero_features_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the zero-feature case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 1.0], vec![0.0, 1.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y: Vec = vec![0, 1]; + let result = DecisionTreeClassifier::fit(&no_features, &y, Default::default()); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test