# 7.4. Minimal Builds and Modular Integration
Most of this guide treats RustyML as a framework. You build a `Sequential`. You fit a `KMeans`. It owns the pipeline end to end. RustyML does not require this.
The crate splits into 5 feature-gated modules. Each module compiles on its own. Any single module works as a standalone toolbox, in a system that has no other knowledge of RustyML. Use `metrics` alone to score predictions from PyTorch weights ported to `candle`. Use `math` alone for the distance and reduction primitives. Use `utils` alone to standardize and split a dataset before you hand it to another learner.
This page shows how to build these slim profiles, what each one costs, and how the Cargo feature system can undo the trimming without warning. Read [1.2. Installation and Feature Flags](../Chapter-01/1.2._Installation_and_Feature_Flags.md) first. This page covers leaf builds and the dependency graph, not the full default build.
## 7.4.1. The feature graph and what each profile pulls in
The crate defines 5 module features: `machine_learning`, `neural_network`, `utils`, `metrics`, and `math`. It also defines 1 aggregate feature, `full`, and 1 orthogonal switch, `show_progress`. The default feature set is `full`, so it enables all 5 modules.
Every module feature enables `math`, and `math` enables the numeric backend crates without condition. `math` names `ndarray`, `ahash`, `rayon`, and `gemmkit-ndarray`, and the adapter brings the `gemmkit` engine with it. So no RustyML build exists without those 5 crates. Enabling any module feature adds all of them. The heavier features add serialization and RNG machinery on top. `Cargo.toml`'s optional-dependency list gives this per-feature dependency set:
| Optional dep | `math` | `metrics` | `utils` | `machine_learning` | `neural_network` |
|---|---|---|---|---|---|
| `ndarray` 0.17 | yes | yes | yes | yes | yes |
| `ahash` | yes | yes | yes | yes | yes (via `math`) |
| `rayon` | yes | yes (via `math`) | yes | yes | yes |
| `gemmkit-ndarray` (`epilogue`) | yes | yes (via `math`) | yes (via `math`) | yes (via `math`) | yes (via `math`) |
| `gemmkit` (not direct, via `gemmkit-ndarray`) | yes | yes | yes | yes | yes |
| `ndarray-rand` | no | no | yes | yes | yes |
| `serde` | no | no | yes | yes | yes |
| `postcard` | no | no | yes | yes | yes |
| `thiserror` | no | no | yes | yes | yes |
| `indicatif` | no | no | no | no | yes |
Look closely at 2 rows in this table. `metrics` looks like the lightest leaf, and its dependency count is small. But it routes through `math`, so it still compiles `rayon` and the whole `gemmkit` matrix-multiply backend. This includes the `gemmkit-ndarray` adapter, with the `epilogue` feature the neural-network layers need. This holds even though a metric like `mean_squared_error` never multiplies a matrix. You pay this build-time cost for the `math` edge, and Cargo does not prune it away just because a build never calls it.
The other row to watch is `indicatif`. The `neural_network` feature lists it as a hard dependency, so it compiles whenever neural networks are on. Most of the code that uses it sits behind the separate `show_progress` feature gate, so `neural_network` without `show_progress` compiles `indicatif` but never calls it. `show_progress` gates progress bars for most iterative estimators in `machine_learning`. Examples include `KMeans`, `DBSCAN`, `MeanShift`, `PCA`, `KernelPCA`, `LDA`, `IsolationForest`, `LinearRegression`, `LogisticRegression`, `SVC`, `LinearSVC`, `DecisionTree`, and `TSNE`. The same feature also gates the neural-network training loop, and it pulls `indicatif` on its own edge, separate from `neural_network`.
Only 2 profiles stay genuinely slim: `math` and `metrics`. Both skip `serde`, `postcard`, `ndarray-rand`, `thiserror`, and `indicatif` entirely. Every feature from `utils` upward adds the serialization stack, because those modules carry state you can persist (see [7.2. Model Persistence in Depth](./7.2._Model_Persistence_in_Depth.md)) and use randomized initialization.
## 7.4.2. A metrics-only build: scoring predictions from anything
`metrics` is the most reusable slice of the crate. Its functions are pure `array -> scalar` maps. They hold no model state, run no training, and take no ownership of the pipeline. This makes them a scoring layer for predictions from any other system. Declare `metrics` with the default stack turned off:
```toml
[dependencies]
rustyml = { version = "0.14", default-features = false, features = ["metrics"] }
ndarray = "0.17"
```
`default-features = false` matters here. It is not a cosmetic detail. The default feature set is `["full"]`, every module in the crate. Leave the default on, and the build re-enables all 5 stacks next to `metrics`. That defeats the point of a slim build.
Turn the default off, and the build compiles `metrics`, its `math` dependency, and the 5 backend crates. It drops the serialization stack and `indicatif`.
Metric functions take `(y_true, y_pred)`, ground truth first. Unlike the rest of the crate, they **panic** instead of returning a `Result`. This is a deliberate design choice for this tier. `metrics` is a leaf that lists only `ndarray` and `ahash` directly. `rayon` and the `gemmkit` pair still come from `math`, as the table above shows.
`metrics` does not even compile the crate's `error` module, because that module needs `machine_learning`, `neural_network`, or `utils`, and none of those are enabled here. On a length mismatch or an empty input, a metric function panics with a message that mirrors the crate's error wording. This matches how `ndarray` itself panics on a shape mismatch. Treat a metrics call as an assertion over arrays you already validated, not as a boundary that can fail gracefully.
```rust
use ndarray::Array1;
use rustyml::metrics::{ConfusionMatrix, mean_squared_error, r2_score, roc_auc};
fn main() {
// Predictions from another system, as plain Vecs.
let y_true = Array1::from_vec(vec![3.0, -0.5, 2.0, 7.0]);
let y_pred = Array1::from_vec(vec![2.5, 0.0, 2.0, 8.0]);
println!("MSE = {}", mean_squared_error(&y_true, &y_pred));
println!("R2 = {}", r2_score(&y_true, &y_pred));
// Binary classification: hard labels through a confusion matrix.
let labels = Array1::from_vec(vec![1.0, 0.0, 0.0, 1.0, 1.0]);
let preds = Array1::from_vec(vec![1.0, 0.0, 1.0, 1.0, 0.0]);
let cm = ConfusionMatrix::new(&labels, &preds);
println!("F1 = {:.3}, accuracy = {:.3}", cm.f1_score(), cm.accuracy());
// Ranked scores through AUC. Labels are `bool` here, scores are `f64`.
let truth = Array1::from_vec(vec![false, true, false, true]);
let scores = Array1::from_vec(vec![0.1, 0.4, 0.35, 0.8]);
println!("AUC = {}", roc_auc(&truth, &scores));
}
```
Look at the type signatures the API fixes. `roc_auc` needs `labels: bool` and `scores: f64`. The label vector is a true boolean, not a `0.0`/`1.0` float column. `ConfusionMatrix::new` requires labels and predictions that are already exactly `0.0` or `1.0`, and it panics on any other value. It does not threshold a probability for you.
[5.1. Regression Metrics](../Chapter-05/5.1._Regression_Metrics.md), [5.2. Classification Metrics](../Chapter-05/5.2._Classification_Metrics.md), and [5.3. Clustering Metrics](../Chapter-05/5.3._Clustering_Metrics.md) list everything this build can reach. The silhouette score, `silhouette_score`, is the only place `metrics` leans on `rayon`, for a parallel pairwise-distance fill. That is why `rayon` still compiles even in this profile.
## 7.4.3. A math-only build: the numeric primitives
`math` is the floor of the crate: the shared primitives every estimator calls. As a standalone build, its public surface is narrower than its internal code. You can import and call 3 pairwise distance functions: `squared_euclidean_distance_row`, `manhattan_distance_row`, and `minkowski_distance_row`. You can also call the `DistanceCalculationMetric` dispatcher, re-exported at `rustyml::math::*`, and the deterministic reductions `det_reduce` and `det_reduce_range` under `rustyml::math::reduction`.
The tiling-strategy helpers `gemm_chunk_rows` and `cache_resident`, under `rustyml::math::matmul`, are not part of this surface. They carry `#[doc(hidden)]` as crate-internal policy hooks with no stability guarantee, and docs.rs does not show them. To influence tiling, use the `tuning::matmul` knobs instead: `set_/get_chunk_elems` and `set_/get_cache_resident_max_bytes`. The backend's own scheduling knobs are reachable through `tuning::matmul::backend`, a re-export of `gemmkit_ndarray::tuning`, or as `GEMMKIT_*` environment variables. Neither `gemm_chunk_rows` nor `cache_resident` reaches them.
The GEMM/GEMV matrix product itself is not public. Layers and estimators call the `gemmkit` adapter directly. The `matmul` module adds 2 entry points on top of it, `dot_par` and `matvec`, and both stay crate-internal. The module exposes only the sizing helpers publicly. It never exposes a public `matmul(a, b)` entry point.
[6.2. Matrix Multiplication](../Chapter-06/6.2._Matrix_Multiplication.md) describes that engine as internal. For a standalone matrix product, call `ndarray`'s `.dot()` directly instead. A math-only build is, in practice, a distances-and-reductions build.
```rust
use ndarray::array;
use rustyml::math::reduction::det_reduce;
use rustyml::math::{DistanceCalculationMetric, squared_euclidean_distance_row};
fn main() {
// Pairwise distance primitives take 1-D array references and return f64.
let a = array![1.0_f64, 2.0, 3.0];
let b = array![4.0_f64, 6.0, 8.0];
println!("squared L2 = {}", squared_euclidean_distance_row(&a, &b));
// The configurable dispatcher takes views and returns a scalar. It matches
// once over the variant.
let metric = DistanceCalculationMetric::Minkowski(3.0);
println!("L3 = {}", metric.distance(a.view(), b.view()));
// A deterministic blocked reduction. The `true`/`false` flag is only a
// performance hint. Both paths fold the same fixed-size blocks in the same order.
let data: Vec<f64> = (0..10_000).map(|i| i as f64).collect();
let sum = det_reduce(
&data,
true,
|block| block.iter().copied().sum::<f64>(),
|x, y| x + y,
0.0,
);
println!("sum = {}", sum);
}
```
Use `det_reduce` instead of a bare `par_iter().sum()` for reproducibility. A work-stealing parallel sum groups its partial floats by whatever the scheduler decided at run time. The last rounding bit then drifts between runs and thread counts. `det_reduce` fixes the grouping into `DET_REDUCE_BLOCK`-sized chunks, folded in index order.
So the `parallel` flag chooses only where the blocks run, never what they compute. This property is the whole point of the module. It connects to the seeding story in [7.1. Reproducibility and Random Seeds](./7.1._Reproducibility_and_Random_Seeds.md).
A math-only build has 1 consequence to remember. `math` is **not** part of the prelude. The prelude re-exports `machine_learning`, `metrics`, `neural_network`, and `utils`, and has no `math` category. So `use rustyml::prelude::*` imports nothing in a math-only build.
You must use `rustyml::math::...` paths directly instead. This holds no matter which other features are on. The distance and reduction primitives stay namespaced and never flatten into the prelude. Contrast this with [1.5. The Prelude and Imports](../Chapter-01/1.5._The_Prelude_and_Imports.md).
## 7.4.4. A utils-only build: preprocessing in a data pipeline
`utils` is the preprocessing slice: standardization, normalization, label encoding, and train/test splitting. It works as a data-prep stage that feeds a learner from another library. Unlike `metrics` and `math`, this is a heavier profile. It adds `serde`, `postcard`, `ndarray-rand`, and `thiserror` on top of the backend crates.
It adds `ndarray-rand` because the splitter shuffles with a seedable RNG. It adds `serde` and `postcard` because a fitted `StandardScaler` persists through the same `save_to_path`/`load_from_path` pair the models use. `utils` also compiles the `error` module, gated on `utils` among other features. Unlike the metrics tier, these functions return `Result<_, Error>` instead of panicking.
The crate-root `traits` module also survives in this profile. `StandardScaler` implements `Fit`, `Transform`, and `FitTransform`. A utils-only build still gets the estimator contract, even with `machine_learning` off.
```toml
[dependencies]
rustyml = { version = "0.14", default-features = false, features = ["utils"] }
ndarray = "0.17"
```
```rust
use ndarray::{Array1, Array2};
use rustyml::utils::StandardScaler;
use rustyml::utils::normalize::{NormalizationAxis, NormalizationOrder, normalize};
use rustyml::utils::standardize::{StandardizationAxis, standardize};
use rustyml::utils::train_test_split::train_test_split;
fn main() {
let x = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
let y = Array1::from_vec(vec![0i32, 1, 0, 1]);
// Per-feature z-scores. Column axis standardizes each feature independently.
let z = standardize(&x, StandardizationAxis::Column).unwrap();
println!("standardized shape = {:?}", z.dim());
// Per-row unit L2 norm.
let n = normalize(&x, NormalizationAxis::Row, NormalizationOrder::L2).unwrap();
println!("first row = {:?}", n.row(0));
// Split consumes its inputs. Args are (x, y, test_size, random_state).
let (x_train, x_test, y_train, y_test) = train_test_split(x, y, Some(0.25), Some(42)).unwrap();
println!("train {} / test {} rows", x_train.nrows(), x_test.nrows());
let _ = (y_train, y_test);
// Fit the scaling on the training rows. Hand the frozen statistics to
// another library, or save them next to the model that consumes the features.
let mut scaler = StandardScaler::new();
let x_train_z = scaler.fit_transform(&x_train).unwrap();
let x_test_z = scaler.transform(&x_test).unwrap();
println!("scaled {} train / {} test rows", x_train_z.nrows(), x_test_z.nrows());
scaler.save_to_path("scaler.bin").unwrap();
}
```
`train_test_split` takes `x` and `y` by value. It moves them into the shuffled partition. The `Some(42)` seed makes the split reproducible. Pass `None` to use the global seed instead.
[4.1. Train-Test Split](../Chapter-04/4.1._Train_Test_Split.md), [4.2. Standardization and Normalization](../Chapter-04/4.2._Standardization_and_Normalization.md), and [4.3. Label Encoding](../Chapter-04/4.3._Label_Encoding.md) cover each transform in detail. In a utils-only build, the prelude is populated with the utils category. So `use rustyml::prelude::*` works here. Importing the specific `standardize` and `normalize` submodules, as shown above, still keeps each call site clear about where the function lives.
## 7.4.5. `default-features = false` and the gotchas that follow
Turning the default off makes a slim build slim. It also removes more than the modules you left out, without warning. 3 things vanish here, and people often trip on them.
**The default estimator and layer stack disappears.** `default = ["full"]` enables all 5 modules. A build that turns the default off and asks only for `metrics` has no `LinearRegression`, no `Sequential`, and no `KMeans`. This is clear in hindsight. In practice it surfaces as a confusing "cannot find `Sequential` in `rustyml`" error, when someone copies a snippet from [3.1. The Sequential Model](../Chapter-03/3.1._The_Sequential_Model.md) into a metrics-only crate.
**The prelude shrinks to match.** `rustyml::prelude` always compiles, but each category inside it is feature-gated. With `default-features = false, features = ["metrics"]`, `use rustyml::prelude::*` brings in only the metrics items. With `features = ["math"]`, it brings in nothing, because `math` has no prelude category. A missing model type after a glob import of the prelude almost always comes from this.
**The `error` and `random` modules go missing under some feature sets.** Both need `machine_learning`, `neural_network`, or `utils`, not `metrics` or `math`. So `rustyml::error::Error`, `rustyml::random::set_global_seed`, and the top-level `set_global_seed`/`clear_global_seed` re-exports do not exist in a metrics-only or math-only build. This is consistent, not a bug. The metrics tier panics instead of returning `Error`. The distance and reduction primitives are stateless and deterministic, so they need no RNG to seed.
The `tuning` module, in contrast, is available in every profile, because it is gated on any of the 5 module features. Which gate setters it exposes still narrows with the feature set. A metrics-only build gets `tuning::metrics::set_silhouette` and the `math`-gated reduction and matmul knobs, and nothing for the neural-network layers. See [7.3. Performance Tuning and Parallelism](./7.3._Performance_Tuning_and_Parallelism.md) for what those knobs do.
## 7.4.6. Feature unification across a workspace
This is the failure mode that undoes careful slimming. Cargo unifies features across the entire dependency graph, per crate, per build. Consider this case. Your binary depends on `rustyml` with `features = ["metrics"], default-features = false`. Some other crate in the same build might also depend on `rustyml` with `features = ["full"]`. This could be a workspace sibling, a transitive dependency, or a dev-dependency in the same compilation.
Cargo then compiles 1 `rustyml`, with the union of every requested feature. Your "metrics-only" build quietly becomes a full build. `gemmkit`, `indicatif`, and the whole estimator stack all come along. Your `Cargo.toml` line alone cannot stop this.
The same unification applies to `default-features`. The default is additive and sticky. Cargo disables it only if every dependency edge onto `rustyml`, in the resolved graph, sets `default-features = false`. A single edge that omits this setting re-enables `full`, all 5 modules, for the whole graph. Setting `default-features = false` is a claim about 1 edge. It is not a claim about the whole build.
The consequences are concrete. Do not rely on a slim feature set for correctness. Never gate your own code on an assumption that `serde`, for example, is absent, because a sibling crate can add it back in. Slim builds are a best-effort optimization for the leaf case, a standalone binary or a workspace where you control every edge. They are not a guarantee. When you need a minimal artifact, verify what actually compiled instead of trusting the manifest:
```bash
# Which features did rustyml actually resolve to in this build?
cargo tree -e features -i rustyml
# Which crates got pulled in at all? Check for gemmkit and indicatif.
cargo tree | grep -E 'gemmkit|indicatif|ndarray-rand|serde'
```
`cargo tree -i rustyml`, the inverse view, shows every crate that depends on `rustyml`, and with which features. Use it to find the sibling that re-enabled `full`.
## 7.4.7. docs.rs shows the whole crate, not your build
RustyML's `Cargo.toml` sets `[package.metadata.docs.rs] all-features = true`. The rendered docs at <https://docs.rs/rustyml> build with every feature on. Whatever slim profile you compile, the documentation you read describes the union of all features.
There is a second, sharper problem. The crate does not annotate items with `#[doc(cfg(...))]` feature badges. So on docs.rs, an item like `Sequential` or `set_global_seed` shows no marker for which feature gates it. The page reads as though the whole surface exists without condition.
Together, these 2 facts create a trap. You can read a function on docs.rs, call it, and get a "cannot find" error. The cause is that your feature set does not include its module. The feature table in [1.2. Installation and Feature Flags](../Chapter-01/1.2._Installation_and_Feature_Flags.md), and the per-feature dependency table above, are the ground truth for what compiles under which flag. docs.rs is the ground truth for what the API looks like when everything is on. Keep these 2 jobs separate.
## 7.4.8. Living next to candle, burn, and other ndarray consumers
You usually run a slim RustyML build because the modeling happens elsewhere. This might be a `candle` or `burn` network, or a `tract`-loaded ONNX graph. You want RustyML for 1 job: scoring, preprocessing, or a distance kernel. The integration seam is data. Matching the `ndarray` version matters most.
RustyML pins `ndarray = "0.17.2"`. To the compiler, an `Array1<f64>` from `ndarray` 0.17 and an `Array1<f64>` from `ndarray` 0.16 are 2 different types from 2 different crates. Cargo compiles both versions into the graph without complaint. A value from one version then fails to pass to a function that expects the other. The type error reads as though 2 identical types are incompatible, because, semantically, they are 2 types.
When you combine RustyML with another crate that also uses `ndarray` in its public API, align both crates on `0.17`. Otherwise you will fight duplicate-version clashes. Run `cargo tree | grep ndarray` to check right away whether 2 versions resolved.
`candle` and `burn` avoid this problem. They do not expose `ndarray` at all, and instead use their own tensor types. This makes the interop cleaner, because there is no version to align.
You cross the boundary through plain slices. Get predictions out of the other framework as a `Vec<f32>` or `Vec<f64>`. Copy them into an `ndarray` array once, and score them with RustyML. The copy costs real time, but it runs once at the boundary. It also keeps the 2 type systems from ever needing to agree.
```rust
use ndarray::Array1;
use rustyml::metrics::{mean_absolute_error, r2_score};
// Stands in for a model in another framework, such as candle, burn, or tract.
// Any of them can hand back predictions as a slice of f32.
fn external_model_predict(inputs: &[f32]) -> Vec<f32> {
inputs.iter().map(|&x| 2.0 * x + 1.0).collect()
}
fn main() {
let inputs = [0.0f32, 1.0, 2.0, 3.0];
let raw_preds = external_model_predict(&inputs);
// Cross the boundary once. Copy into ndarray f64, the dtype every metric expects.
let y_pred: Array1<f64> = raw_preds.iter().map(|&v| v as f64).collect();
let y_true = Array1::from_vec(vec![1.0, 3.0, 5.0, 7.2]);
println!("MAE = {}", mean_absolute_error(&y_true, &y_pred));
println!("R2 = {}", r2_score(&y_true, &y_pred));
}
```
Notice the dtype conversion inside the copy. RustyML's metrics operate on `f64`, while `candle` and `burn` inference typically runs in `f32`. Widening the type at the boundary, in the same pass as the `Vec`-to-`ndarray` copy, is the cheapest place to pay for it.
A final caution relates to feature unification. If you add both RustyML and a large modeling framework to the same workspace, run `cargo tree -e features -i rustyml` afterward. Big frameworks sometimes add RustyML-adjacent utility crates that re-enable features you thought you turned off. The slim-build exercise is only worth doing if you confirm it survived the whole graph.