Skip to main content

rill_ml/preprocessing/
standard_scaler.rs

1//! Online standard scaler.
2//!
3//! Maintains per-feature Welford variance and mean. Time complexity per
4//! update/transform: `O(d)`. Space complexity: `O(d)`.
5
6use crate::error::{RillError, checked_increment, ensure_finite, validate_features};
7use crate::traits::Transformer;
8
9/// Configuration for [`StandardScaler`].
10#[derive(Debug, Clone)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct StandardScalerConfig {
13    /// Whether to subtract the running mean. Default: `true`.
14    pub with_mean: bool,
15    /// Whether to divide by the running standard deviation. Default: `true`.
16    pub with_std: bool,
17    /// Variance threshold below which the scale is treated as `1.0` to avoid
18    /// division by zero. Default: `1e-12`.
19    pub epsilon: f64,
20}
21
22impl Default for StandardScalerConfig {
23    fn default() -> Self {
24        Self {
25            with_mean: true,
26            with_std: true,
27            epsilon: 1e-12,
28        }
29    }
30}
31
32/// Online standard scaler that standardizes features to approximately zero
33/// mean and unit variance.
34///
35/// - When `with_mean = false`, the mean subtraction is skipped.
36/// - When `with_std = false`, the scaling is skipped.
37/// - When a feature has seen zero samples, its mean is `0` and scale is `1`,
38///   so the original value is returned unchanged.
39/// - When a feature's variance is below `epsilon`, the scale is `1` to avoid
40///   NaN or Infinity.
41///
42/// `transform` does not update state; only `update` does.
43#[derive(Debug, Clone)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize))]
45pub struct StandardScaler {
46    feature_count: usize,
47    config: StandardScalerConfig,
48    counts: Vec<u64>,
49    means: Vec<f64>,
50    m2s: Vec<f64>,
51}
52
53impl StandardScaler {
54    /// Create a new scaler for `feature_count` features with default config.
55    pub fn new(feature_count: usize) -> Result<Self, RillError> {
56        Self::with_config(feature_count, StandardScalerConfig::default())
57    }
58
59    /// Create a new scaler with a custom configuration.
60    pub fn with_config(
61        feature_count: usize,
62        config: StandardScalerConfig,
63    ) -> Result<Self, RillError> {
64        if feature_count == 0 {
65            return Err(RillError::EmptyFeatures);
66        }
67        ensure_finite("epsilon", config.epsilon)?;
68        if config.epsilon < 0.0 {
69            return Err(RillError::InvalidParameter {
70                name: "epsilon",
71                value: config.epsilon,
72            });
73        }
74        Ok(Self {
75            feature_count,
76            config,
77            counts: vec![0; feature_count],
78            means: vec![0.0; feature_count],
79            m2s: vec![0.0; feature_count],
80        })
81    }
82
83    /// The per-feature means.
84    pub fn means(&self) -> &[f64] {
85        &self.means
86    }
87
88    /// The per-feature variances (population).
89    pub fn variances(&self) -> Vec<f64> {
90        self.m2s
91            .iter()
92            .zip(&self.counts)
93            .map(|(&m2, &n)| if n == 0 { 0.0 } else { m2 / n as f64 })
94            .collect()
95    }
96
97    /// The per-feature standard deviations.
98    pub fn std_devs(&self) -> Vec<f64> {
99        self.variances().iter().map(|v| v.sqrt()).collect()
100    }
101
102    /// The per-feature scales used during transformation.
103    pub fn scales(&self) -> Vec<f64> {
104        self.variances()
105            .iter()
106            .map(|&var| {
107                if var < self.config.epsilon {
108                    1.0
109                } else {
110                    var.sqrt()
111                }
112            })
113            .collect()
114    }
115
116    /// Validate all configuration and persisted-state invariants.
117    ///
118    /// This is run automatically during deserialization and before operations
119    /// that index per-feature state. The hot path (`transform_into` /
120    /// `transform`) relies on the invariants established here and by the
121    /// private constructor, and only re-checks them under `debug_assert!`.
122    pub fn validate(&self) -> Result<(), RillError> {
123        if self.feature_count == 0 {
124            return Err(RillError::EmptyFeatures);
125        }
126        ensure_finite("epsilon", self.config.epsilon)?;
127        if self.config.epsilon < 0.0 {
128            return Err(RillError::InvalidParameter {
129                name: "epsilon",
130                value: self.config.epsilon,
131            });
132        }
133        if self.counts.len() != self.feature_count
134            || self.means.len() != self.feature_count
135            || self.m2s.len() != self.feature_count
136        {
137            return Err(RillError::InvalidState(
138                "standard scaler feature_count does not match state lengths".to_owned(),
139            ));
140        }
141        if self.means.iter().any(|value| !value.is_finite())
142            || self.m2s.iter().any(|value| !value.is_finite())
143        {
144            return Err(RillError::InvalidState(
145                "standard scaler state must contain only finite values".to_owned(),
146            ));
147        }
148        // ``m2s`` is the running sum of squared deviations from the mean
149        // (Welford M2). It is mathematically non-negative; a negative value
150        // indicates corruption or a maliciously crafted serde payload. The
151        // finite check above already rules out NaN/Infinity, so here we only
152        // need to reject strictly negative values.
153        if self.m2s.iter().any(|value| *value < 0.0) {
154            return Err(RillError::InvalidState(
155                "standard scaler m2s must be non-negative".to_owned(),
156            ));
157        }
158        if self.counts.windows(2).any(|pair| pair[0] != pair[1]) {
159            return Err(RillError::InvalidState(
160                "standard scaler feature counts must stay synchronized".to_owned(),
161            ));
162        }
163        // counts are synchronized (verified above), so the first entry
164        // represents every feature's sample count.
165        let n = self.counts.first().copied().unwrap_or(0);
166        // count == 0: no samples seen → mean and M2 must be exactly 0 for
167        // every feature. The normal paths (new() / reset()) guarantee this;
168        // a non-zero value indicates a corrupted or malicious payload. The
169        // public docs promise mean=0 and scale=1 in this state, so accepting
170        // a non-zero mean would silently break transform() output.
171        if n == 0 {
172            for (i, &mean) in self.means.iter().enumerate() {
173                if mean != 0.0 {
174                    return Err(RillError::InvalidState(format!(
175                        "standard scaler means[{i}] must be 0 when count == 0, got {mean}"
176                    )));
177                }
178            }
179            for (i, &m2) in self.m2s.iter().enumerate() {
180                if m2 != 0.0 {
181                    return Err(RillError::InvalidState(format!(
182                        "standard scaler m2s[{i}] must be 0 when count == 0, got {m2}"
183                    )));
184                }
185            }
186        }
187        // count == 1: after a single Welford update, delta2 = x - mean = 0,
188        // so m2_delta = delta * delta2 = 0 and M2 stays exactly 0. The
189        // normal update path guarantees exact 0, so no floating-point
190        // tolerance is introduced here. The single training sample is
191        // unknown, so mean is NOT constrained to a specific value.
192        if n == 1 {
193            for (i, &m2) in self.m2s.iter().enumerate() {
194                if m2 != 0.0 {
195                    return Err(RillError::InvalidState(format!(
196                        "standard scaler m2s[{i}] must be 0 when count == 1, got {m2}"
197                    )));
198                }
199            }
200        }
201        Ok(())
202    }
203
204    /// Transform `features` into the provided `output` buffer, reusing its
205    /// allocation instead of allocating a fresh `Vec` on every call.
206    ///
207    /// This is the hot-path entry point. Compared to [`transform`](Transformer::transform)
208    /// it avoids two temporary allocations (the `variances()` and `scales()`
209    /// vectors) by fusing the scale computation into the single output loop.
210    /// The trust-boundary checks (dimension validation and finite-output
211    /// enforcement) are preserved; the internal-state invariants established
212    /// by [`validate`](Self::validate) and the private constructor are only
213    /// re-checked under `debug_assert!` because they are guaranteed by the
214    /// private fields and the validated deserialization path.
215    ///
216    /// `output` is truncated to the feature count and then filled; callers
217    /// that reuse the same buffer across iterations avoid allocation
218    /// entirely after the first call.
219    pub fn transform_into(&self, features: &[f64], output: &mut Vec<f64>) -> Result<(), RillError> {
220        // Trust-boundary: dimension must match. This is the public input
221        // boundary and must always be enforced.
222        validate_features(self.feature_count, features)?;
223
224        // Internal invariants are guaranteed by the private constructor and
225        // the validated Deserialize impl; re-check only in debug builds so
226        // release-mode hot paths do not pay for repeated O(d) scans.
227        debug_assert!(
228            self.counts.len() == self.feature_count
229                && self.means.len() == self.feature_count
230                && self.m2s.len() == self.feature_count,
231            "standard scaler state lengths must match feature_count"
232        );
233        debug_assert!(
234            self.counts.windows(2).all(|pair| pair[0] == pair[1]),
235            "standard scaler feature counts must stay synchronized"
236        );
237        debug_assert!(
238            self.means.iter().all(|v| v.is_finite()) && self.m2s.iter().all(|v| v.is_finite()),
239            "standard scaler state must contain only finite values"
240        );
241
242        output.clear();
243        output.reserve(self.feature_count);
244
245        let iter = features
246            .iter()
247            .zip(&self.counts)
248            .zip(&self.means)
249            .zip(&self.m2s);
250        for (((&x, &n), &mean_storage), &m2) in iter {
251            // Population variance = m2 / n; if n == 0 the scale is 1.0 so
252            // the original value is returned unchanged.
253            let scale = if !self.config.with_std || n == 0 {
254                1.0
255            } else {
256                let variance = m2 / n as f64;
257                if variance < self.config.epsilon {
258                    1.0
259                } else {
260                    variance.sqrt()
261                }
262            };
263            let mean = if self.config.with_mean {
264                mean_storage
265            } else {
266                0.0
267            };
268            let transformed = (x - mean) / scale;
269            // Trust-boundary: output must be finite. This catches NaN/Inf
270            // introduced by adversarial input even when internal state is
271            // already validated.
272            ensure_finite("transformed feature", transformed)?;
273            output.push(transformed);
274        }
275        Ok(())
276    }
277}
278
279impl Transformer for StandardScaler {
280    fn input_dim(&self) -> usize {
281        self.feature_count
282    }
283
284    fn output_dim(&self) -> usize {
285        self.feature_count
286    }
287
288    fn transform(&self, features: &[f64]) -> Result<Vec<f64>, RillError> {
289        // Hot path: rely on the invariants established by the private
290        // constructor and the validated Deserialize impl (private fields
291        // cannot be set to an inconsistent state from outside the crate).
292        // Only input dimension and output finiteness are checked on the
293        // release path; full state scanning is left to `validate()` and
294        // `debug_assert!` inside `transform_into`.
295        let mut output = Vec::with_capacity(self.feature_count);
296        self.transform_into(features, &mut output)?;
297        Ok(output)
298    }
299
300    fn update(&mut self, features: &[f64]) -> Result<(), RillError> {
301        self.validate()?;
302        validate_features(self.feature_count, features)?;
303        let mut next_counts = self.counts.clone();
304        let mut next_means = self.means.clone();
305        let mut next_m2s = self.m2s.clone();
306        for (i, &x) in features.iter().enumerate() {
307            let count = checked_increment(self.counts[i], "standard scaler sample")?;
308            let delta = x - self.means[i];
309            ensure_finite("standard scaler delta", delta)?;
310            let mean = self.means[i] + delta / count as f64;
311            ensure_finite("standard scaler mean", mean)?;
312            let delta2 = x - mean;
313            ensure_finite("standard scaler delta", delta2)?;
314            let m2 = self.m2s[i] + delta * delta2;
315            ensure_finite("standard scaler M2", m2)?;
316            next_counts[i] = count;
317            next_means[i] = mean;
318            next_m2s[i] = m2;
319        }
320        self.counts = next_counts;
321        self.means = next_means;
322        self.m2s = next_m2s;
323        Ok(())
324    }
325
326    fn samples_seen(&self) -> u64 {
327        self.counts.iter().copied().max().unwrap_or(0)
328    }
329
330    fn reset(&mut self) {
331        for c in &mut self.counts {
332            *c = 0;
333        }
334        for m in &mut self.means {
335            *m = 0.0;
336        }
337        for m2 in &mut self.m2s {
338            *m2 = 0.0;
339        }
340    }
341}
342
343#[cfg(feature = "serde")]
344#[derive(serde::Deserialize)]
345struct StandardScalerState {
346    feature_count: usize,
347    config: StandardScalerConfig,
348    counts: Vec<u64>,
349    means: Vec<f64>,
350    m2s: Vec<f64>,
351}
352
353#[cfg(feature = "serde")]
354impl<'de> serde::Deserialize<'de> for StandardScaler {
355    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
356    where
357        D: serde::Deserializer<'de>,
358    {
359        let state = StandardScalerState::deserialize(deserializer)?;
360        let scaler = Self {
361            feature_count: state.feature_count,
362            config: state.config,
363            counts: state.counts,
364            means: state.means,
365            m2s: state.m2s,
366        };
367        scaler.validate().map_err(serde::de::Error::custom)?;
368        Ok(scaler)
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn scaler_zero_state_returns_original() {
378        let s = StandardScaler::new(3).unwrap();
379        let out = s.transform(&[1.0, 2.0, 3.0]).unwrap();
380        // count == 0 -> mean=0, scale=1 -> original
381        assert!((out[0] - 1.0).abs() < 1e-12);
382        assert!((out[1] - 2.0).abs() < 1e-12);
383        assert!((out[2] - 3.0).abs() < 1e-12);
384    }
385
386    #[test]
387    fn scaler_standardizes_after_updates() {
388        let mut s = StandardScaler::new(2).unwrap();
389        // feature 0: values [1, 3] -> mean 2, var 1, std 1
390        // feature 1: values [10, 20] -> mean 15, var 25, std 5
391        s.update(&[1.0, 10.0]).unwrap();
392        s.update(&[3.0, 20.0]).unwrap();
393        let out = s.transform(&[3.0, 20.0]).unwrap();
394        // (3-2)/1 = 1, (20-15)/5 = 1
395        assert!((out[0] - 1.0).abs() < 1e-9);
396        assert!((out[1] - 1.0).abs() < 1e-9);
397    }
398
399    #[test]
400    fn transform_does_not_update_state() {
401        let mut s = StandardScaler::new(1).unwrap();
402        s.update(&[10.0]).unwrap();
403        let mean_before = s.means()[0];
404        let _ = s.transform(&[5.0]).unwrap();
405        assert_eq!(s.means()[0], mean_before);
406        assert_eq!(s.counts[0], 1);
407    }
408
409    #[test]
410    fn update_rejects_overflow_without_mutating_state() {
411        let mut scaler = StandardScaler::new(1).unwrap();
412        scaler.update(&[f64::MAX]).unwrap();
413        let before = scaler.clone();
414        assert!(scaler.update(&[-f64::MAX]).is_err());
415        assert_eq!(scaler.counts, before.counts);
416        assert_eq!(scaler.means, before.means);
417        assert_eq!(scaler.m2s, before.m2s);
418    }
419
420    #[cfg(feature = "serde")]
421    #[test]
422    fn serde_rejects_malformed_state() {
423        let malformed = r#"{
424            "feature_count":2,
425            "config":{"with_mean":true,"with_std":true,"epsilon":1e-12},
426            "counts":[1],
427            "means":[0.0],
428            "m2s":[0.0]
429        }"#;
430        assert!(serde_json::from_str::<StandardScaler>(malformed).is_err());
431    }
432
433    #[cfg(feature = "serde")]
434    #[test]
435    fn serde_rejects_negative_m2() {
436        // Regression: a malicious or corrupted state with a negative Welford
437        // M2 must be rejected. ``m2`` is a sum of squared deviations and is
438        // mathematically non-negative; accepting a negative value would let
439        // an attacker poison the scaler with imaginary variances.
440        let malformed = r#"{
441            "feature_count":1,
442            "config":{"with_mean":true,"with_std":true,"epsilon":1e-12},
443            "counts":[2],
444            "means":[0.0],
445            "m2s":[-1.0]
446        }"#;
447        assert!(serde_json::from_str::<StandardScaler>(malformed).is_err());
448    }
449
450    #[cfg(feature = "serde")]
451    #[test]
452    fn scaler_serde_rejects_zero_count_nonzero_mean() {
453        // count == 0 promises mean=0, scale=1 (input returned unchanged).
454        // A non-zero mean would silently break transform() output.
455        let malformed = r#"{
456            "feature_count":1,
457            "config":{"with_mean":true,"with_std":true,"epsilon":1e-12},
458            "counts":[0],
459            "means":[10.0],
460            "m2s":[0.0]
461        }"#;
462        assert!(
463            serde_json::from_str::<StandardScaler>(malformed).is_err(),
464            "count=0 with non-zero mean must be rejected"
465        );
466    }
467
468    #[cfg(feature = "serde")]
469    #[test]
470    fn scaler_serde_rejects_zero_count_nonzero_m2() {
471        // count == 0 implies no samples → M2 (sum of squared deviations)
472        // must be exactly 0. A non-zero M2 is a corrupted/malicious state.
473        let malformed = r#"{
474            "feature_count":1,
475            "config":{"with_mean":true,"with_std":true,"epsilon":1e-12},
476            "counts":[0],
477            "means":[0.0],
478            "m2s":[1.0]
479        }"#;
480        assert!(
481            serde_json::from_str::<StandardScaler>(malformed).is_err(),
482            "count=0 with non-zero m2 must be rejected"
483        );
484    }
485
486    #[cfg(feature = "serde")]
487    #[test]
488    fn scaler_serde_rejects_one_count_nonzero_m2() {
489        // count == 1: after a single Welford update, delta2 = x - mean = 0,
490        // so M2 stays exactly 0. The training sample itself is unknown, so
491        // mean is NOT constrained — only M2 must be 0.
492        let malformed = r#"{
493            "feature_count":1,
494            "config":{"with_mean":true,"with_std":true,"epsilon":1e-12},
495            "counts":[1],
496            "means":[3.0],
497            "m2s":[0.25]
498        }"#;
499        assert!(
500            serde_json::from_str::<StandardScaler>(malformed).is_err(),
501            "count=1 with non-zero m2 must be rejected"
502        );
503    }
504
505    #[cfg(feature = "serde")]
506    #[test]
507    fn scaler_serde_accepts_one_count_finite_mean() {
508        // count == 1 with an arbitrary finite mean and M2 == 0 is the
509        // legitimate post-single-update state (mean == the single training
510        // sample). It must round-trip successfully.
511        let json = r#"{
512            "feature_count":2,
513            "config":{"with_mean":true,"with_std":true,"epsilon":1e-12},
514            "counts":[1,1],
515            "means":[3.0,-7.5],
516            "m2s":[0.0,0.0]
517        }"#;
518        let scaler: StandardScaler =
519            serde_json::from_str(json).expect("count=1 with finite mean and m2=0 must be accepted");
520        // Round-trip back to JSON and re-parse to confirm stability.
521        let re = serde_json::to_string(&scaler).unwrap();
522        let _: StandardScaler = serde_json::from_str(&re).unwrap();
523        assert_eq!(scaler.counts, vec![1, 1]);
524        assert_eq!(scaler.means, vec![3.0, -7.5]);
525        assert_eq!(scaler.m2s, vec![0.0, 0.0]);
526    }
527
528    #[cfg(feature = "serde")]
529    #[test]
530    fn scaler_serde_roundtrip_preserves_state() {
531        // A scaler trained on a few samples must survive a serialize →
532        // deserialize round-trip with all invariants intact.
533        let mut scaler = StandardScaler::new(2).unwrap();
534        scaler.update(&[1.0, 10.0]).unwrap();
535        scaler.update(&[3.0, 20.0]).unwrap();
536        scaler.update(&[5.0, 30.0]).unwrap();
537        let json = serde_json::to_string(&scaler).unwrap();
538        let restored: StandardScaler = serde_json::from_str(&json).unwrap();
539        assert_eq!(restored.counts, scaler.counts);
540        assert_eq!(restored.means, scaler.means);
541        assert_eq!(restored.m2s, scaler.m2s);
542        // transform output must match as well.
543        let features = [2.0, 15.0];
544        assert_eq!(
545            scaler.transform(&features).unwrap(),
546            restored.transform(&features).unwrap()
547        );
548    }
549
550    #[test]
551    fn transform_hot_path_does_not_scan_invariants() {
552        // The release transform() hot path must remain a single loop and
553        // must NOT re-run the O(d) invariant scan from validate(). This
554        // test constructs a valid scaler and confirms transform() succeeds
555        // without invoking validate() (which would walk counts/means/m2s).
556        // We can't directly count scans, but we can confirm a scaler that
557        // has valid invariants transforms correctly and that the hot path
558        // doesn't change state.
559        let mut scaler = StandardScaler::new(3).unwrap();
560        scaler.update(&[1.0, 2.0, 3.0]).unwrap();
561        scaler.update(&[4.0, 5.0, 6.0]).unwrap();
562        let before = scaler.clone();
563        let out = scaler.transform(&[2.5, 3.5, 4.5]).unwrap();
564        // State must be unchanged.
565        assert_eq!(scaler.counts, before.counts);
566        assert_eq!(scaler.means, before.means);
567        assert_eq!(scaler.m2s, before.m2s);
568        // Output length matches feature count (single loop).
569        assert_eq!(out.len(), 3);
570    }
571
572    #[test]
573    fn constant_feature_uses_scale_one() {
574        let mut s = StandardScaler::new(1).unwrap();
575        for _ in 0..10 {
576            s.update(&[5.0]).unwrap();
577        }
578        // var = 0 < epsilon -> scale = 1, mean = 5 -> (5-5)/1 = 0
579        let out = s.transform(&[5.0]).unwrap();
580        assert!(out[0].abs() < 1e-12);
581        assert!(!out[0].is_nan());
582    }
583
584    #[test]
585    fn with_mean_false_keeps_offset() {
586        let mut s = StandardScaler::with_config(
587            1,
588            StandardScalerConfig {
589                with_mean: false,
590                with_std: true,
591                epsilon: 1e-12,
592            },
593        )
594        .unwrap();
595        s.update(&[1.0]).unwrap();
596        s.update(&[3.0]).unwrap();
597        // mean=2, var=1, std=1, but with_mean=false so x/1 = x
598        let out = s.transform(&[3.0]).unwrap();
599        assert!((out[0] - 3.0).abs() < 1e-9);
600    }
601
602    #[test]
603    fn dimension_mismatch_rejected() {
604        let mut s = StandardScaler::new(3).unwrap();
605        assert!(s.transform(&[1.0, 2.0]).is_err());
606        assert!(s.update(&[1.0, 2.0]).is_err());
607    }
608
609    #[test]
610    fn zero_features_rejected() {
611        assert!(matches!(
612            StandardScaler::new(0),
613            Err(RillError::EmptyFeatures)
614        ));
615    }
616
617    #[test]
618    fn non_finite_rejected() {
619        let mut s = StandardScaler::new(2).unwrap();
620        assert!(s.update(&[1.0, f64::NAN]).is_err());
621    }
622
623    #[test]
624    fn reset_clears_state() {
625        let mut s = StandardScaler::new(1).unwrap();
626        s.update(&[1.0]).unwrap();
627        s.update(&[2.0]).unwrap();
628        s.reset();
629        assert_eq!(s.counts[0], 0);
630        assert_eq!(s.means()[0], 0.0);
631    }
632
633    #[test]
634    fn transform_into_matches_transform_output() {
635        let mut scaler = StandardScaler::new(4).unwrap();
636        // Feed a few samples so means/variances are non-trivial.
637        scaler.update(&[1.0, 10.0, 100.0, 1000.0]).unwrap();
638        scaler.update(&[3.0, 20.0, 300.0, 3000.0]).unwrap();
639        scaler.update(&[5.0, 30.0, 500.0, 5000.0]).unwrap();
640        let features = [2.0, 15.0, 200.0, 2000.0];
641        let via_transform = scaler.transform(&features).unwrap();
642        let mut via_into = Vec::new();
643        scaler.transform_into(&features, &mut via_into).unwrap();
644        assert_eq!(via_transform, via_into);
645    }
646
647    #[test]
648    fn transform_into_reuses_buffer_capacity() {
649        let mut scaler = StandardScaler::new(3).unwrap();
650        scaler.update(&[1.0, 2.0, 3.0]).unwrap();
651        scaler.update(&[4.0, 5.0, 6.0]).unwrap();
652        let features = [2.5, 3.5, 4.5];
653        let mut buffer = Vec::with_capacity(64);
654        // Prime the buffer with sentinel content to prove clear() is called.
655        buffer.extend_from_slice(&[-1.0, -2.0, -3.0, -4.0]);
656        scaler.transform_into(&features, &mut buffer).unwrap();
657        assert_eq!(buffer.len(), 3);
658        // Capacity must be preserved (no reallocation).
659        assert!(buffer.capacity() >= 64);
660        // Content must match the public transform() output.
661        assert_eq!(buffer, scaler.transform(&features).unwrap());
662    }
663
664    #[test]
665    fn transform_into_rejects_dimension_mismatch() {
666        let scaler = StandardScaler::new(3).unwrap();
667        let mut buffer = Vec::new();
668        assert!(scaler.transform_into(&[1.0, 2.0], &mut buffer).is_err());
669        // Buffer must remain empty after the dimension error.
670        assert!(buffer.is_empty());
671    }
672
673    #[test]
674    fn transform_into_rejects_non_finite_output() {
675        // with_std = false and a non-finite input must still be caught by
676        // the finite-output trust-boundary check.
677        let scaler = StandardScaler::with_config(
678            1,
679            StandardScalerConfig {
680                with_mean: false,
681                with_std: false,
682                epsilon: 1e-12,
683            },
684        )
685        .unwrap();
686        let mut buffer = Vec::new();
687        assert!(scaler.transform_into(&[f64::NAN], &mut buffer).is_err());
688    }
689
690    #[test]
691    fn transform_into_with_zero_state_returns_original() {
692        let scaler = StandardScaler::new(3).unwrap();
693        let features = [1.5, 2.5, 3.5];
694        let mut buffer = Vec::new();
695        scaler.transform_into(&features, &mut buffer).unwrap();
696        // count == 0 → mean = 0, scale = 1 → original values.
697        assert_eq!(buffer, vec![1.5, 2.5, 3.5]);
698    }
699}