Skip to main content

ipfrs_tensorlogic/
ml_feature_extractor.rs

1//! ML preprocessing `FeatureExtractor` — composable feature engineering pipeline.
2//!
3//! Provides a production-grade, composable pipeline for feature engineering including
4//! scaling, encoding, imputation, and polynomial expansion. Designed to transform a
5//! `HashMap<String, FeatureValue>` input into a flat `Vec<f64>` suitable for ML models.
6//!
7//! # Example
8//!
9//! ```
10//! use ipfrs_tensorlogic::ml_feature_extractor::{
11//!     FeatureExtractor, FeatureSpec, FeatureTransform, FeatureValue,
12//! };
13//! use std::collections::HashMap;
14//!
15//! let mut extractor = FeatureExtractor::new();
16//! extractor.add_spec(FeatureSpec {
17//!     name: "age".to_string(),
18//!     transforms: vec![
19//!         FeatureTransform::StandardScaler { mean: 30.0, std: 10.0 },
20//!     ],
21//! });
22//!
23//! let mut input = HashMap::new();
24//! input.insert("age".to_string(), FeatureValue::Float(40.0));
25//!
26//! let result = extractor.extract(&input).expect("example: should succeed in docs");
27//! assert_eq!(result.values.len(), 1);
28//! // (40 - 30) / 10 = 1.0
29//! assert!((result.values[0] - 1.0).abs() < 1e-12);
30//! ```
31
32use std::collections::HashMap;
33use std::sync::atomic::{AtomicU64, Ordering};
34use thiserror::Error;
35
36// ─────────────────────────────────────────────────────────────────────────────
37// Error type
38// ─────────────────────────────────────────────────────────────────────────────
39
40/// Errors produced by the feature extraction pipeline.
41#[derive(Debug, Error, Clone, PartialEq)]
42pub enum FeatureError {
43    /// A required feature was not found and no imputer was configured.
44    #[error("missing required feature: {0}")]
45    MissingFeature(String),
46
47    /// A transform received a value of the wrong type.
48    #[error("type mismatch for feature '{feature}': expected {expected}, got {got}")]
49    TypeMismatch {
50        feature: String,
51        expected: String,
52        got: String,
53    },
54
55    /// The transform parameters are invalid (e.g., degree = 0).
56    #[error("invalid transform: {0}")]
57    InvalidTransform(String),
58
59    /// The input batch or data slice is empty.
60    #[error("empty input")]
61    EmptyInput,
62}
63
64// ─────────────────────────────────────────────────────────────────────────────
65// Core value type
66// ─────────────────────────────────────────────────────────────────────────────
67
68/// A polymorphic feature value that flows through the transform chain.
69#[derive(Clone, Debug, PartialEq)]
70pub enum FeatureValue {
71    /// Continuous floating-point scalar.
72    Float(f64),
73    /// Integer scalar — coerced to `f64` for numeric transforms.
74    Integer(i64),
75    /// Boolean flag — coerced to `1.0` / `0.0` for numeric transforms.
76    Boolean(bool),
77    /// Nominal string category — used by `OneHotEncode` and `ImputeMode`.
78    Categorical(String),
79    /// Absent / unknown value — consumed by imputer transforms.
80    Missing,
81}
82
83impl FeatureValue {
84    /// Return the type name as a human-readable string (used in error messages).
85    pub fn type_name(&self) -> &'static str {
86        match self {
87            FeatureValue::Float(_) => "Float",
88            FeatureValue::Integer(_) => "Integer",
89            FeatureValue::Boolean(_) => "Boolean",
90            FeatureValue::Categorical(_) => "Categorical",
91            FeatureValue::Missing => "Missing",
92        }
93    }
94
95    /// Coerce to `f64` if the variant is numeric.  Returns `None` for `Categorical`
96    /// and `Missing`.
97    pub fn as_f64(&self) -> Option<f64> {
98        match self {
99            FeatureValue::Float(v) => Some(*v),
100            FeatureValue::Integer(v) => Some(*v as f64),
101            FeatureValue::Boolean(b) => Some(if *b { 1.0 } else { 0.0 }),
102            FeatureValue::Categorical(_) | FeatureValue::Missing => None,
103        }
104    }
105}
106
107// ─────────────────────────────────────────────────────────────────────────────
108// Transform enum
109// ─────────────────────────────────────────────────────────────────────────────
110
111/// A single reversible or irreversible transform applied to a `FeatureValue`.
112#[derive(Clone, Debug, PartialEq)]
113pub enum FeatureTransform {
114    /// Z-score normalisation: `(x - mean) / std`.  Output is `0.0` when `std ≈ 0`.
115    StandardScaler { mean: f64, std: f64 },
116
117    /// Min-max normalisation: `(x - min) / (max - min)`.  Output is `0.0` when range ≈ 0.
118    MinMaxScaler { min: f64, max: f64 },
119
120    /// Signed log1p: `ln(1 + |x|) × sign(x)`.
121    Log1p,
122
123    /// Signed square-root: `√|x| × sign(x)`.
124    Sqrt,
125
126    /// Clamp the value to `[lo, hi]`.
127    Clip { lo: f64, hi: f64 },
128
129    /// One-hot encode a `Categorical` value — produces one `f64` per category.
130    /// Unknown categories produce an all-zero vector.
131    OneHotEncode { categories: Vec<String> },
132
133    /// Threshold binarisation: `1.0` if `x > threshold` else `0.0`.
134    Binarize { threshold: f64 },
135
136    /// Polynomial expansion to `degree`: `[x, x², …, xᵈ]`.
137    PolynomialFeatures { degree: u32 },
138
139    /// Replace `Missing` with a pre-computed numeric mean.
140    ImputeMean { mean: f64 },
141
142    /// Replace a `Missing` categorical with the pre-computed mode string.
143    ImputeMode { mode: String },
144}
145
146// ─────────────────────────────────────────────────────────────────────────────
147// Spec and output types
148// ─────────────────────────────────────────────────────────────────────────────
149
150/// An ordered list of transforms to apply to a single named input feature.
151#[derive(Clone, Debug)]
152pub struct FeatureSpec {
153    /// Name of the feature to look up in the input map.
154    pub name: String,
155    /// Sequential transforms to apply.  Applied in order, left-to-right.
156    pub transforms: Vec<FeatureTransform>,
157}
158
159/// The fully-expanded output of one extraction call.
160#[derive(Clone, Debug)]
161pub struct ExtractedFeatures {
162    /// Output feature names — one per scalar value.  OneHot expands to
163    /// `{name}_cat_{i}` entries.
164    pub feature_names: Vec<String>,
165    /// Flat output vector aligned with `feature_names`.
166    pub values: Vec<f64>,
167}
168
169// ─────────────────────────────────────────────────────────────────────────────
170// Internal representation used during transform evaluation
171// ─────────────────────────────────────────────────────────────────────────────
172
173/// Intermediate value during pipeline evaluation — either a scalar or the
174/// expanded binary vector produced by `OneHotEncode`.
175#[derive(Clone, Debug)]
176enum PipelineValue {
177    Scalar(FeatureValue),
178    OneHot(Vec<f64>),
179}
180
181// ─────────────────────────────────────────────────────────────────────────────
182// Stats
183// ─────────────────────────────────────────────────────────────────────────────
184
185/// Cumulative statistics for a [`FeatureExtractor`] instance.
186#[derive(Clone, Debug, Default)]
187pub struct FePipelineStats {
188    /// Total number of named output features (sum of per-spec output dims).
189    pub total_features: usize,
190    /// Total number of `extract` calls made (including batch calls).
191    pub total_extractions: u64,
192    /// Running average of the output dimension across all extractions.
193    pub avg_output_dim: f64,
194}
195
196// ─────────────────────────────────────────────────────────────────────────────
197// FeatureExtractor
198// ─────────────────────────────────────────────────────────────────────────────
199
200/// Composable feature engineering pipeline.
201///
202/// Holds an ordered list of [`FeatureSpec`]s.  Each spec names one input
203/// feature and chains transforms that convert it to one or more `f64` outputs.
204///
205/// # Thread-safety
206///
207/// `FeatureExtractor` is not `Send`/`Sync` by default because of its internal
208/// `AtomicU64` counter — if you need concurrent access, wrap it in a `Mutex`.
209pub struct FeatureExtractor {
210    specs: Vec<FeatureSpec>,
211    total_extractions: AtomicU64,
212    extraction_dim_sum: AtomicU64,
213}
214
215impl FeatureExtractor {
216    /// Create an empty extractor with no specs.
217    pub fn new() -> Self {
218        Self {
219            specs: Vec::new(),
220            total_extractions: AtomicU64::new(0),
221            extraction_dim_sum: AtomicU64::new(0),
222        }
223    }
224
225    /// Append a spec to the pipeline (builder-style).
226    pub fn add_spec(&mut self, spec: FeatureSpec) -> &mut Self {
227        self.specs.push(spec);
228        self
229    }
230
231    /// Compute the total output dimension from spec metadata alone.
232    ///
233    /// For `OneHotEncode { categories }` the contribution is
234    /// `categories.len()`.  For `PolynomialFeatures { degree }` it is
235    /// `degree as usize`.  All other specs contribute `1`.
236    pub fn output_dim(&self) -> usize {
237        self.specs.iter().map(spec_output_dim).sum()
238    }
239
240    /// List all output feature names (expanded for OneHot and Polynomial).
241    pub fn feature_names(&self) -> Vec<String> {
242        let mut names = Vec::with_capacity(self.output_dim());
243        for spec in &self.specs {
244            push_feature_names(spec, &mut names);
245        }
246        names
247    }
248
249    /// Extract features from a single `input` map.
250    ///
251    /// Returns an error if a required feature is missing (no imputer configured)
252    /// or a type mismatch prevents a transform from running.
253    pub fn extract(
254        &self,
255        input: &HashMap<String, FeatureValue>,
256    ) -> Result<ExtractedFeatures, FeatureError> {
257        let mut feature_names: Vec<String> = Vec::with_capacity(self.output_dim());
258        let mut values: Vec<f64> = Vec::with_capacity(self.output_dim());
259
260        for spec in &self.specs {
261            let raw = input
262                .get(&spec.name)
263                .cloned()
264                .unwrap_or(FeatureValue::Missing);
265
266            let pipeline_val = apply_transforms(spec, raw)?;
267
268            match pipeline_val {
269                PipelineValue::Scalar(fv) => {
270                    let v = scalar_to_f64(&spec.name, fv)?;
271                    feature_names.push(spec.name.clone());
272                    values.push(v);
273                }
274                PipelineValue::OneHot(vec) => {
275                    for (i, v) in vec.into_iter().enumerate() {
276                        feature_names.push(format!("{}_cat_{}", spec.name, i));
277                        values.push(v);
278                    }
279                }
280            }
281        }
282
283        // Update statistics atomically.
284        let dim = values.len() as u64;
285        self.total_extractions.fetch_add(1, Ordering::Relaxed);
286        self.extraction_dim_sum.fetch_add(dim, Ordering::Relaxed);
287
288        Ok(ExtractedFeatures {
289            feature_names,
290            values,
291        })
292    }
293
294    /// Extract features from a batch of input maps.
295    ///
296    /// Returns `Err(FeatureError::EmptyInput)` when `inputs` is empty.
297    pub fn extract_batch(
298        &self,
299        inputs: &[HashMap<String, FeatureValue>],
300    ) -> Result<Vec<ExtractedFeatures>, FeatureError> {
301        if inputs.is_empty() {
302            return Err(FeatureError::EmptyInput);
303        }
304        inputs.iter().map(|inp| self.extract(inp)).collect()
305    }
306
307    /// Return a snapshot of accumulated statistics.
308    pub fn stats(&self) -> FePipelineStats {
309        let total_extractions = self.total_extractions.load(Ordering::Relaxed);
310        let dim_sum = self.extraction_dim_sum.load(Ordering::Relaxed);
311        let avg_output_dim = if total_extractions == 0 {
312            0.0
313        } else {
314            dim_sum as f64 / total_extractions as f64
315        };
316        FePipelineStats {
317            total_features: self.output_dim(),
318            total_extractions,
319            avg_output_dim,
320        }
321    }
322}
323
324impl Default for FeatureExtractor {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330// ─────────────────────────────────────────────────────────────────────────────
331// Fitting helpers (stateless functions)
332// ─────────────────────────────────────────────────────────────────────────────
333
334/// Fit a `StandardScaler` from a slice of observed values.
335///
336/// Computes population mean and std-dev.  When `values` is empty or has
337/// zero variance the returned `std` field is `0.0`.
338pub fn fit_standard_scaler(values: &[f64]) -> FeatureTransform {
339    if values.is_empty() {
340        return FeatureTransform::StandardScaler {
341            mean: 0.0,
342            std: 0.0,
343        };
344    }
345    let n = values.len() as f64;
346    let mean = values.iter().copied().sum::<f64>() / n;
347    let variance = values.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n;
348    let std = variance.sqrt();
349    FeatureTransform::StandardScaler { mean, std }
350}
351
352/// Fit a `MinMaxScaler` from a slice of observed values.
353///
354/// Uses the global min and max.  When `values` is empty both fields are `0.0`.
355pub fn fit_minmax_scaler(values: &[f64]) -> FeatureTransform {
356    if values.is_empty() {
357        return FeatureTransform::MinMaxScaler { min: 0.0, max: 0.0 };
358    }
359    let mut min = values[0];
360    let mut max = values[0];
361    for &v in &values[1..] {
362        if v < min {
363            min = v;
364        }
365        if v > max {
366            max = v;
367        }
368    }
369    FeatureTransform::MinMaxScaler { min, max }
370}
371
372/// Fit a `OneHotEncode` transform from an observed set of category strings.
373///
374/// Deduplicates and sorts the categories for deterministic ordering.
375pub fn fit_onehot(categories: &[String]) -> FeatureTransform {
376    let mut cats: Vec<String> = categories.to_vec();
377    cats.sort();
378    cats.dedup();
379    FeatureTransform::OneHotEncode { categories: cats }
380}
381
382// ─────────────────────────────────────────────────────────────────────────────
383// Internal helpers
384// ─────────────────────────────────────────────────────────────────────────────
385
386/// Apply all transforms in `spec` to `value`, returning the pipeline result.
387fn apply_transforms(
388    spec: &FeatureSpec,
389    value: FeatureValue,
390) -> Result<PipelineValue, FeatureError> {
391    let mut current = PipelineValue::Scalar(value);
392
393    for transform in &spec.transforms {
394        current = apply_one_transform(spec, transform, current)?;
395    }
396
397    Ok(current)
398}
399
400/// Apply a single transform to the current pipeline value.
401fn apply_one_transform(
402    spec: &FeatureSpec,
403    transform: &FeatureTransform,
404    current: PipelineValue,
405) -> Result<PipelineValue, FeatureError> {
406    match transform {
407        // ── Imputers (only consume Missing) ────────────────────────────────
408        FeatureTransform::ImputeMean { mean } => match current {
409            PipelineValue::Scalar(FeatureValue::Missing) => {
410                Ok(PipelineValue::Scalar(FeatureValue::Float(*mean)))
411            }
412            other => Ok(other),
413        },
414
415        FeatureTransform::ImputeMode { mode } => match current {
416            PipelineValue::Scalar(FeatureValue::Missing) => Ok(PipelineValue::Scalar(
417                FeatureValue::Categorical(mode.clone()),
418            )),
419            other => Ok(other),
420        },
421
422        // ── Numeric scalar transforms ───────────────────────────────────────
423        FeatureTransform::StandardScaler { mean, std } => {
424            let x = require_numeric(spec, current)?;
425            let result = if std.abs() < f64::EPSILON {
426                0.0
427            } else {
428                (x - mean) / std
429            };
430            Ok(PipelineValue::Scalar(FeatureValue::Float(result)))
431        }
432
433        FeatureTransform::MinMaxScaler { min, max } => {
434            let x = require_numeric(spec, current)?;
435            let range = max - min;
436            let result = if range.abs() < f64::EPSILON {
437                0.0
438            } else {
439                (x - min) / range
440            };
441            Ok(PipelineValue::Scalar(FeatureValue::Float(result)))
442        }
443
444        FeatureTransform::Log1p => {
445            let x = require_numeric(spec, current)?;
446            let sign = if x >= 0.0 { 1.0 } else { -1.0 };
447            Ok(PipelineValue::Scalar(FeatureValue::Float(
448                (1.0 + x.abs()).ln() * sign,
449            )))
450        }
451
452        FeatureTransform::Sqrt => {
453            let x = require_numeric(spec, current)?;
454            let sign = if x >= 0.0 { 1.0 } else { -1.0 };
455            Ok(PipelineValue::Scalar(FeatureValue::Float(
456                x.abs().sqrt() * sign,
457            )))
458        }
459
460        FeatureTransform::Clip { lo, hi } => {
461            let x = require_numeric(spec, current)?;
462            Ok(PipelineValue::Scalar(FeatureValue::Float(
463                x.max(*lo).min(*hi),
464            )))
465        }
466
467        FeatureTransform::Binarize { threshold } => {
468            let x = require_numeric(spec, current)?;
469            Ok(PipelineValue::Scalar(FeatureValue::Float(
470                if x > *threshold { 1.0 } else { 0.0 },
471            )))
472        }
473
474        FeatureTransform::PolynomialFeatures { degree } => {
475            let x = require_numeric(spec, current)?;
476            if *degree == 0 {
477                return Err(FeatureError::InvalidTransform(
478                    "PolynomialFeatures degree must be >= 1".to_string(),
479                ));
480            }
481            // Polynomial returns a multi-value representation stored as OneHot for consistency.
482            // We model it as a FeatureValue::Float after unrolling via a synthetic OneHot vec.
483            let mut poly_vals = Vec::with_capacity(*degree as usize);
484            let mut pow = x;
485            for _ in 1..=*degree {
486                poly_vals.push(pow);
487                pow *= x;
488            }
489            Ok(PipelineValue::OneHot(poly_vals))
490        }
491
492        // ── OneHotEncode ───────────────────────────────────────────────────
493        FeatureTransform::OneHotEncode { categories } => {
494            let cat = require_categorical(spec, current)?;
495            let one_hot: Vec<f64> = categories
496                .iter()
497                .map(|c| if c == &cat { 1.0 } else { 0.0 })
498                .collect();
499            Ok(PipelineValue::OneHot(one_hot))
500        }
501    }
502}
503
504/// Extract a numeric `f64` from a `PipelineValue::Scalar`.
505/// Returns `FeatureError::MissingFeature` for `Missing` and `FeatureError::TypeMismatch` for
506/// non-numeric types.
507fn require_numeric(spec: &FeatureSpec, pv: PipelineValue) -> Result<f64, FeatureError> {
508    match pv {
509        PipelineValue::Scalar(fv) => match fv.as_f64() {
510            Some(v) => Ok(v),
511            None => {
512                let type_name = fv.type_name();
513                if type_name == "Missing" {
514                    Err(FeatureError::MissingFeature(spec.name.clone()))
515                } else {
516                    Err(FeatureError::TypeMismatch {
517                        feature: spec.name.clone(),
518                        expected: "numeric (Float/Integer/Boolean)".to_string(),
519                        got: type_name.to_string(),
520                    })
521                }
522            }
523        },
524        PipelineValue::OneHot(v) => {
525            // Polynomial output can flow through numeric transforms by using the first element.
526            // But in practice this is an error — reject it.
527            Err(FeatureError::TypeMismatch {
528                feature: spec.name.clone(),
529                expected: "numeric scalar".to_string(),
530                got: format!("multi-value vector of length {}", v.len()),
531            })
532        }
533    }
534}
535
536/// Extract a `String` from a `Categorical` pipeline value.
537fn require_categorical(spec: &FeatureSpec, pv: PipelineValue) -> Result<String, FeatureError> {
538    match pv {
539        PipelineValue::Scalar(FeatureValue::Categorical(s)) => Ok(s),
540        PipelineValue::Scalar(FeatureValue::Missing) => {
541            Err(FeatureError::MissingFeature(spec.name.clone()))
542        }
543        PipelineValue::Scalar(fv) => Err(FeatureError::TypeMismatch {
544            feature: spec.name.clone(),
545            expected: "Categorical".to_string(),
546            got: fv.type_name().to_string(),
547        }),
548        PipelineValue::OneHot(v) => Err(FeatureError::TypeMismatch {
549            feature: spec.name.clone(),
550            expected: "Categorical".to_string(),
551            got: format!("multi-value vector of length {}", v.len()),
552        }),
553    }
554}
555
556/// Convert a final scalar `FeatureValue` to `f64` for output.
557fn scalar_to_f64(name: &str, fv: FeatureValue) -> Result<f64, FeatureError> {
558    match fv.as_f64() {
559        Some(v) => Ok(v),
560        None => {
561            let type_name = fv.type_name();
562            if type_name == "Missing" {
563                Err(FeatureError::MissingFeature(name.to_string()))
564            } else {
565                Err(FeatureError::TypeMismatch {
566                    feature: name.to_string(),
567                    expected: "numeric scalar".to_string(),
568                    got: type_name.to_string(),
569                })
570            }
571        }
572    }
573}
574
575/// Compute the number of scalar outputs produced by a spec.
576fn spec_output_dim(spec: &FeatureSpec) -> usize {
577    // Walk transforms in reverse to find the last dimensionality-changing transform.
578    for transform in spec.transforms.iter().rev() {
579        match transform {
580            FeatureTransform::OneHotEncode { categories } => return categories.len(),
581            FeatureTransform::PolynomialFeatures { degree } => return *degree as usize,
582            _ => {}
583        }
584    }
585    1
586}
587
588/// Populate `names` with the output feature names for one spec.
589fn push_feature_names(spec: &FeatureSpec, names: &mut Vec<String>) {
590    let dim = spec_output_dim(spec);
591    if dim == 1 {
592        names.push(spec.name.clone());
593    } else {
594        // OneHot or Polynomial expansion.
595        for i in 0..dim {
596            names.push(format!("{}_cat_{}", spec.name, i));
597        }
598    }
599}
600
601// ─────────────────────────────────────────────────────────────────────────────
602// Tests
603// ─────────────────────────────────────────────────────────────────────────────
604
605#[cfg(test)]
606mod tests {
607    use std::collections::HashMap;
608
609    use crate::ml_feature_extractor::{
610        fit_minmax_scaler, fit_onehot, fit_standard_scaler, FeatureError, FeatureExtractor,
611        FeatureSpec, FeatureTransform, FeatureValue,
612    };
613
614    // ── helpers ──────────────────────────────────────────────────────────────
615
616    fn single_spec(name: &str, transforms: Vec<FeatureTransform>) -> FeatureSpec {
617        FeatureSpec {
618            name: name.to_string(),
619            transforms,
620        }
621    }
622
623    fn make_input(name: &str, val: FeatureValue) -> HashMap<String, FeatureValue> {
624        let mut m = HashMap::new();
625        m.insert(name.to_string(), val);
626        m
627    }
628
629    fn extract_scalar(spec: FeatureSpec, val: FeatureValue) -> f64 {
630        let mut ex = FeatureExtractor::new();
631        ex.add_spec(spec.clone());
632        let input = make_input(&spec.name, val);
633        let res = ex.extract(&input).expect("test: should succeed");
634        res.values[0]
635    }
636
637    // ── StandardScaler ───────────────────────────────────────────────────────
638
639    #[test]
640    fn test_standard_scaler_basic() {
641        let t = FeatureTransform::StandardScaler {
642            mean: 10.0,
643            std: 2.0,
644        };
645        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(14.0));
646        assert!((v - 2.0).abs() < 1e-12, "v={v}");
647    }
648
649    #[test]
650    fn test_standard_scaler_zero_std() {
651        let t = FeatureTransform::StandardScaler {
652            mean: 5.0,
653            std: 0.0,
654        };
655        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(99.0));
656        assert_eq!(v, 0.0, "zero std must produce 0.0");
657    }
658
659    #[test]
660    fn test_standard_scaler_negative_result() {
661        let t = FeatureTransform::StandardScaler {
662            mean: 10.0,
663            std: 2.0,
664        };
665        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(6.0));
666        assert!((v - (-2.0)).abs() < 1e-12, "v={v}");
667    }
668
669    #[test]
670    fn test_standard_scaler_integer_input() {
671        let t = FeatureTransform::StandardScaler {
672            mean: 0.0,
673            std: 1.0,
674        };
675        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Integer(3));
676        assert!((v - 3.0).abs() < 1e-12);
677    }
678
679    #[test]
680    fn test_standard_scaler_boolean_input() {
681        let t = FeatureTransform::StandardScaler {
682            mean: 0.0,
683            std: 1.0,
684        };
685        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Boolean(true));
686        assert!((v - 1.0).abs() < 1e-12);
687    }
688
689    // ── MinMaxScaler ──────────────────────────────────────────────────────────
690
691    #[test]
692    fn test_minmax_scaler_midpoint() {
693        let t = FeatureTransform::MinMaxScaler {
694            min: 0.0,
695            max: 10.0,
696        };
697        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(5.0));
698        assert!((v - 0.5).abs() < 1e-12, "v={v}");
699    }
700
701    #[test]
702    fn test_minmax_scaler_at_min() {
703        let t = FeatureTransform::MinMaxScaler {
704            min: 0.0,
705            max: 10.0,
706        };
707        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.0));
708        assert!((v - 0.0).abs() < 1e-12);
709    }
710
711    #[test]
712    fn test_minmax_scaler_at_max() {
713        let t = FeatureTransform::MinMaxScaler {
714            min: 0.0,
715            max: 10.0,
716        };
717        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(10.0));
718        assert!((v - 1.0).abs() < 1e-12);
719    }
720
721    #[test]
722    fn test_minmax_scaler_zero_range() {
723        let t = FeatureTransform::MinMaxScaler { min: 5.0, max: 5.0 };
724        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(5.0));
725        assert_eq!(v, 0.0, "zero range must produce 0.0");
726    }
727
728    // ── Log1p ─────────────────────────────────────────────────────────────────
729
730    #[test]
731    fn test_log1p_positive() {
732        let t = FeatureTransform::Log1p;
733        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.0));
734        assert!((v - 0.0).abs() < 1e-12, "ln(1+0)=0, v={v}");
735    }
736
737    #[test]
738    fn test_log1p_positive_value() {
739        let t = FeatureTransform::Log1p;
740        // ln(1 + 1) = ln(2)
741        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(1.0));
742        assert!((v - 2.0_f64.ln()).abs() < 1e-12, "v={v}");
743    }
744
745    #[test]
746    fn test_log1p_negative_value() {
747        // ln(1+|−3|) × sign(−3) = ln(4) × (−1)
748        let t = FeatureTransform::Log1p;
749        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(-3.0));
750        let expected = -(4.0_f64.ln());
751        assert!((v - expected).abs() < 1e-12, "v={v}, expected={expected}");
752    }
753
754    // ── Sqrt ──────────────────────────────────────────────────────────────────
755
756    #[test]
757    fn test_sqrt_positive() {
758        let t = FeatureTransform::Sqrt;
759        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(9.0));
760        assert!((v - 3.0).abs() < 1e-12, "v={v}");
761    }
762
763    #[test]
764    fn test_sqrt_negative() {
765        let t = FeatureTransform::Sqrt;
766        // √4 × (−1) = −2.0
767        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(-4.0));
768        assert!((v - (-2.0)).abs() < 1e-12, "v={v}");
769    }
770
771    #[test]
772    fn test_sqrt_zero() {
773        let t = FeatureTransform::Sqrt;
774        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.0));
775        assert_eq!(v, 0.0);
776    }
777
778    // ── Clip ──────────────────────────────────────────────────────────────────
779
780    #[test]
781    fn test_clip_below_lo() {
782        let t = FeatureTransform::Clip { lo: 0.0, hi: 1.0 };
783        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(-5.0));
784        assert_eq!(v, 0.0);
785    }
786
787    #[test]
788    fn test_clip_above_hi() {
789        let t = FeatureTransform::Clip { lo: 0.0, hi: 1.0 };
790        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(10.0));
791        assert_eq!(v, 1.0);
792    }
793
794    #[test]
795    fn test_clip_within_range() {
796        let t = FeatureTransform::Clip { lo: 0.0, hi: 1.0 };
797        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.5));
798        assert!((v - 0.5).abs() < 1e-12);
799    }
800
801    // ── Binarize ──────────────────────────────────────────────────────────────
802
803    #[test]
804    fn test_binarize_above_threshold() {
805        let t = FeatureTransform::Binarize { threshold: 0.5 };
806        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.7));
807        assert_eq!(v, 1.0);
808    }
809
810    #[test]
811    fn test_binarize_below_threshold() {
812        let t = FeatureTransform::Binarize { threshold: 0.5 };
813        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.3));
814        assert_eq!(v, 0.0);
815    }
816
817    #[test]
818    fn test_binarize_at_threshold() {
819        // Exactly at threshold is NOT above → 0.0
820        let t = FeatureTransform::Binarize { threshold: 0.5 };
821        let v = extract_scalar(single_spec("x", vec![t]), FeatureValue::Float(0.5));
822        assert_eq!(v, 0.0);
823    }
824
825    // ── PolynomialFeatures ────────────────────────────────────────────────────
826
827    #[test]
828    fn test_polynomial_degree2() {
829        let spec = single_spec(
830            "x",
831            vec![FeatureTransform::PolynomialFeatures { degree: 2 }],
832        );
833        let mut ex = FeatureExtractor::new();
834        ex.add_spec(spec);
835        let input = make_input("x", FeatureValue::Float(3.0));
836        let res = ex.extract(&input).expect("test: should succeed");
837        assert_eq!(res.values.len(), 2);
838        assert!((res.values[0] - 3.0).abs() < 1e-12, "x={}", res.values[0]);
839        assert!((res.values[1] - 9.0).abs() < 1e-12, "x²={}", res.values[1]);
840    }
841
842    #[test]
843    fn test_polynomial_degree3() {
844        let spec = single_spec(
845            "x",
846            vec![FeatureTransform::PolynomialFeatures { degree: 3 }],
847        );
848        let mut ex = FeatureExtractor::new();
849        ex.add_spec(spec);
850        let input = make_input("x", FeatureValue::Float(2.0));
851        let res = ex.extract(&input).expect("test: should succeed");
852        assert_eq!(res.values.len(), 3);
853        assert!((res.values[0] - 2.0).abs() < 1e-12);
854        assert!((res.values[1] - 4.0).abs() < 1e-12);
855        assert!((res.values[2] - 8.0).abs() < 1e-12);
856    }
857
858    #[test]
859    fn test_polynomial_degree0_error() {
860        let spec = single_spec(
861            "x",
862            vec![FeatureTransform::PolynomialFeatures { degree: 0 }],
863        );
864        let mut ex = FeatureExtractor::new();
865        ex.add_spec(spec);
866        let input = make_input("x", FeatureValue::Float(1.0));
867        let err = ex.extract(&input).unwrap_err();
868        assert!(matches!(err, FeatureError::InvalidTransform(_)));
869    }
870
871    // ── OneHotEncode ──────────────────────────────────────────────────────────
872
873    #[test]
874    fn test_onehot_known_category() {
875        let cats = vec!["a".to_string(), "b".to_string(), "c".to_string()];
876        let spec = single_spec(
877            "color",
878            vec![FeatureTransform::OneHotEncode { categories: cats }],
879        );
880        let mut ex = FeatureExtractor::new();
881        ex.add_spec(spec);
882        let input = make_input("color", FeatureValue::Categorical("b".to_string()));
883        let res = ex.extract(&input).expect("test: should succeed");
884        assert_eq!(res.values, vec![0.0, 1.0, 0.0]);
885    }
886
887    #[test]
888    fn test_onehot_unknown_category_all_zeros() {
889        let cats = vec!["a".to_string(), "b".to_string()];
890        let spec = single_spec(
891            "x",
892            vec![FeatureTransform::OneHotEncode { categories: cats }],
893        );
894        let mut ex = FeatureExtractor::new();
895        ex.add_spec(spec);
896        let input = make_input("x", FeatureValue::Categorical("z".to_string()));
897        let res = ex.extract(&input).expect("test: should succeed");
898        assert_eq!(res.values, vec![0.0, 0.0]);
899    }
900
901    #[test]
902    fn test_onehot_feature_names_expanded() {
903        let cats = vec!["x".to_string(), "y".to_string()];
904        let spec = single_spec(
905            "col",
906            vec![FeatureTransform::OneHotEncode { categories: cats }],
907        );
908        let mut ex = FeatureExtractor::new();
909        ex.add_spec(spec);
910        let names = ex.feature_names();
911        assert_eq!(names, vec!["col_cat_0", "col_cat_1"]);
912    }
913
914    // ── Imputation ────────────────────────────────────────────────────────────
915
916    #[test]
917    fn test_impute_mean_missing_value() {
918        let t = FeatureTransform::ImputeMean { mean: 42.0 };
919        let spec = single_spec(
920            "x",
921            vec![
922                t,
923                FeatureTransform::StandardScaler {
924                    mean: 42.0,
925                    std: 1.0,
926                },
927            ],
928        );
929        let mut ex = FeatureExtractor::new();
930        ex.add_spec(spec);
931        let input = make_input("x", FeatureValue::Missing);
932        let res = ex.extract(&input).expect("test: should succeed");
933        // (42 - 42) / 1 = 0.0
934        assert!((res.values[0] - 0.0).abs() < 1e-12);
935    }
936
937    #[test]
938    fn test_impute_mean_not_missing_unchanged() {
939        let t = FeatureTransform::ImputeMean { mean: 0.0 };
940        let spec = single_spec("x", vec![t]);
941        let v = extract_scalar(spec, FeatureValue::Float(7.0));
942        assert!((v - 7.0).abs() < 1e-12);
943    }
944
945    #[test]
946    fn test_impute_mode_missing_categorical() {
947        let cats = vec!["cat".to_string(), "dog".to_string()];
948        let spec = single_spec(
949            "pet",
950            vec![
951                FeatureTransform::ImputeMode {
952                    mode: "cat".to_string(),
953                },
954                FeatureTransform::OneHotEncode { categories: cats },
955            ],
956        );
957        let mut ex = FeatureExtractor::new();
958        ex.add_spec(spec);
959        let input = make_input("pet", FeatureValue::Missing);
960        let res = ex.extract(&input).expect("test: should succeed");
961        // "cat" → [1.0, 0.0]
962        assert_eq!(res.values, vec![1.0, 0.0]);
963    }
964
965    // ── MissingFeature error without imputer ──────────────────────────────────
966
967    #[test]
968    fn test_missing_feature_no_imputer_error() {
969        let t = FeatureTransform::StandardScaler {
970            mean: 0.0,
971            std: 1.0,
972        };
973        let spec = single_spec("x", vec![t]);
974        let mut ex = FeatureExtractor::new();
975        ex.add_spec(spec);
976        let input: HashMap<String, FeatureValue> = HashMap::new();
977        let err = ex.extract(&input).unwrap_err();
978        assert!(matches!(err, FeatureError::MissingFeature(ref name) if name == "x"));
979    }
980
981    #[test]
982    fn test_missing_feature_key_not_in_map_error() {
983        let t = FeatureTransform::StandardScaler {
984            mean: 0.0,
985            std: 1.0,
986        };
987        let spec = single_spec("age", vec![t]);
988        let mut ex = FeatureExtractor::new();
989        ex.add_spec(spec);
990        // Insert a different key
991        let input = make_input("height", FeatureValue::Float(1.75));
992        let err = ex.extract(&input).unwrap_err();
993        assert!(matches!(err, FeatureError::MissingFeature(ref name) if name == "age"));
994    }
995
996    // ── Chained transforms ────────────────────────────────────────────────────
997
998    #[test]
999    fn test_chain_clip_then_standard_scaler() {
1000        // Clip to [0, 1] then StandardScaler with mean=0.5, std=0.5
1001        let spec = single_spec(
1002            "x",
1003            vec![
1004                FeatureTransform::Clip { lo: 0.0, hi: 1.0 },
1005                FeatureTransform::StandardScaler {
1006                    mean: 0.5,
1007                    std: 0.5,
1008                },
1009            ],
1010        );
1011        let v = extract_scalar(spec, FeatureValue::Float(100.0));
1012        // Clipped to 1.0, then (1.0 - 0.5) / 0.5 = 1.0
1013        assert!((v - 1.0).abs() < 1e-12, "v={v}");
1014    }
1015
1016    #[test]
1017    fn test_chain_impute_then_log1p() {
1018        let spec = single_spec(
1019            "x",
1020            vec![
1021                FeatureTransform::ImputeMean { mean: 0.0 },
1022                FeatureTransform::Log1p,
1023            ],
1024        );
1025        let v = extract_scalar(spec, FeatureValue::Missing);
1026        // ln(1 + 0) = 0
1027        assert_eq!(v, 0.0);
1028    }
1029
1030    // ── Multi-spec extractor ──────────────────────────────────────────────────
1031
1032    #[test]
1033    fn test_multi_spec_output_order() {
1034        let mut ex = FeatureExtractor::new();
1035        ex.add_spec(single_spec(
1036            "a",
1037            vec![FeatureTransform::StandardScaler {
1038                mean: 0.0,
1039                std: 1.0,
1040            }],
1041        ));
1042        ex.add_spec(single_spec(
1043            "b",
1044            vec![FeatureTransform::MinMaxScaler {
1045                min: 0.0,
1046                max: 10.0,
1047            }],
1048        ));
1049
1050        let mut input = HashMap::new();
1051        input.insert("a".to_string(), FeatureValue::Float(1.0));
1052        input.insert("b".to_string(), FeatureValue::Float(5.0));
1053
1054        let res = ex.extract(&input).expect("test: should succeed");
1055        assert_eq!(res.values.len(), 2);
1056        assert!((res.values[0] - 1.0).abs() < 1e-12); // (1.0-0)/1
1057        assert!((res.values[1] - 0.5).abs() < 1e-12); // (5-0)/10
1058    }
1059
1060    #[test]
1061    fn test_output_dim() {
1062        let mut ex = FeatureExtractor::new();
1063        ex.add_spec(single_spec(
1064            "a",
1065            vec![FeatureTransform::StandardScaler {
1066                mean: 0.0,
1067                std: 1.0,
1068            }],
1069        ));
1070        ex.add_spec(single_spec(
1071            "b",
1072            vec![FeatureTransform::OneHotEncode {
1073                categories: vec!["x".to_string(), "y".to_string(), "z".to_string()],
1074            }],
1075        ));
1076        ex.add_spec(single_spec(
1077            "c",
1078            vec![FeatureTransform::PolynomialFeatures { degree: 2 }],
1079        ));
1080        // 1 + 3 + 2 = 6
1081        assert_eq!(ex.output_dim(), 6);
1082    }
1083
1084    // ── Stats ─────────────────────────────────────────────────────────────────
1085
1086    #[test]
1087    fn test_stats_after_single_extract() {
1088        let mut ex = FeatureExtractor::new();
1089        ex.add_spec(single_spec(
1090            "x",
1091            vec![FeatureTransform::StandardScaler {
1092                mean: 0.0,
1093                std: 1.0,
1094            }],
1095        ));
1096        let input = make_input("x", FeatureValue::Float(1.0));
1097        ex.extract(&input).expect("test: should succeed");
1098        let s = ex.stats();
1099        assert_eq!(s.total_extractions, 1);
1100        assert!((s.avg_output_dim - 1.0).abs() < 1e-12);
1101    }
1102
1103    #[test]
1104    fn test_stats_accumulate_over_batch() {
1105        let mut ex = FeatureExtractor::new();
1106        ex.add_spec(single_spec("x", vec![FeatureTransform::Log1p]));
1107        let batch: Vec<HashMap<String, FeatureValue>> = (0..5)
1108            .map(|i| make_input("x", FeatureValue::Float(i as f64)))
1109            .collect();
1110        ex.extract_batch(&batch).expect("test: should succeed");
1111        assert_eq!(ex.stats().total_extractions, 5);
1112    }
1113
1114    // ── extract_batch ─────────────────────────────────────────────────────────
1115
1116    #[test]
1117    fn test_extract_batch_empty_returns_error() {
1118        let ex = FeatureExtractor::new();
1119        let err = ex.extract_batch(&[]).unwrap_err();
1120        assert_eq!(err, FeatureError::EmptyInput);
1121    }
1122
1123    #[test]
1124    fn test_extract_batch_multiple_inputs() {
1125        let mut ex = FeatureExtractor::new();
1126        ex.add_spec(single_spec(
1127            "v",
1128            vec![FeatureTransform::MinMaxScaler {
1129                min: 0.0,
1130                max: 10.0,
1131            }],
1132        ));
1133        let batch = vec![
1134            make_input("v", FeatureValue::Float(0.0)),
1135            make_input("v", FeatureValue::Float(5.0)),
1136            make_input("v", FeatureValue::Float(10.0)),
1137        ];
1138        let results = ex.extract_batch(&batch).expect("test: should succeed");
1139        assert_eq!(results.len(), 3);
1140        assert!((results[0].values[0] - 0.0).abs() < 1e-12);
1141        assert!((results[1].values[0] - 0.5).abs() < 1e-12);
1142        assert!((results[2].values[0] - 1.0).abs() < 1e-12);
1143    }
1144
1145    // ── Fitting helpers ───────────────────────────────────────────────────────
1146
1147    #[test]
1148    fn test_fit_standard_scaler() {
1149        let t = fit_standard_scaler(&[1.0, 2.0, 3.0, 4.0, 5.0]);
1150        if let FeatureTransform::StandardScaler { mean, std } = t {
1151            assert!((mean - 3.0).abs() < 1e-10, "mean={mean}");
1152            // Population std of [1..5]: variance = 2.0, std = √2
1153            assert!((std - 2.0_f64.sqrt()).abs() < 1e-10, "std={std}");
1154        } else {
1155            panic!("expected StandardScaler");
1156        }
1157    }
1158
1159    #[test]
1160    fn test_fit_standard_scaler_empty() {
1161        let t = fit_standard_scaler(&[]);
1162        assert!(matches!(
1163            t,
1164            FeatureTransform::StandardScaler {
1165                mean: 0.0,
1166                std: 0.0
1167            }
1168        ));
1169    }
1170
1171    #[test]
1172    fn test_fit_minmax_scaler() {
1173        let t = fit_minmax_scaler(&[3.0, 1.0, 7.0, -2.0]);
1174        if let FeatureTransform::MinMaxScaler { min, max } = t {
1175            assert!((min - (-2.0)).abs() < 1e-12);
1176            assert!((max - 7.0).abs() < 1e-12);
1177        } else {
1178            panic!("expected MinMaxScaler");
1179        }
1180    }
1181
1182    #[test]
1183    fn test_fit_minmax_scaler_empty() {
1184        let t = fit_minmax_scaler(&[]);
1185        assert!(matches!(
1186            t,
1187            FeatureTransform::MinMaxScaler { min: 0.0, max: 0.0 }
1188        ));
1189    }
1190
1191    #[test]
1192    fn test_fit_onehot_dedup_sort() {
1193        let cats = vec![
1194            "banana".to_string(),
1195            "apple".to_string(),
1196            "banana".to_string(),
1197            "cherry".to_string(),
1198        ];
1199        let t = fit_onehot(&cats);
1200        if let FeatureTransform::OneHotEncode { categories } = t {
1201            assert_eq!(categories, vec!["apple", "banana", "cherry"]);
1202        } else {
1203            panic!("expected OneHotEncode");
1204        }
1205    }
1206
1207    // ── Type-mismatch errors ──────────────────────────────────────────────────
1208
1209    #[test]
1210    fn test_type_mismatch_categorical_to_scaler() {
1211        let t = FeatureTransform::StandardScaler {
1212            mean: 0.0,
1213            std: 1.0,
1214        };
1215        let spec = single_spec("x", vec![t]);
1216        let mut ex = FeatureExtractor::new();
1217        ex.add_spec(spec);
1218        let input = make_input("x", FeatureValue::Categorical("hello".to_string()));
1219        let err = ex.extract(&input).unwrap_err();
1220        assert!(matches!(err, FeatureError::TypeMismatch { .. }));
1221    }
1222
1223    #[test]
1224    fn test_type_mismatch_float_to_onehot() {
1225        let t = FeatureTransform::OneHotEncode {
1226            categories: vec!["a".to_string()],
1227        };
1228        let spec = single_spec("x", vec![t]);
1229        let mut ex = FeatureExtractor::new();
1230        ex.add_spec(spec);
1231        let input = make_input("x", FeatureValue::Float(1.0));
1232        let err = ex.extract(&input).unwrap_err();
1233        assert!(matches!(err, FeatureError::TypeMismatch { .. }));
1234    }
1235
1236    // ── feature_names alignment ───────────────────────────────────────────────
1237
1238    #[test]
1239    fn test_feature_names_alignment_with_values() {
1240        let mut ex = FeatureExtractor::new();
1241        ex.add_spec(single_spec("score", vec![FeatureTransform::Log1p]));
1242        ex.add_spec(single_spec(
1243            "color",
1244            vec![FeatureTransform::OneHotEncode {
1245                categories: vec!["red".to_string(), "blue".to_string()],
1246            }],
1247        ));
1248
1249        let mut input = HashMap::new();
1250        input.insert("score".to_string(), FeatureValue::Float(1.0));
1251        input.insert(
1252            "color".to_string(),
1253            FeatureValue::Categorical("red".to_string()),
1254        );
1255
1256        let res = ex.extract(&input).expect("test: should succeed");
1257        assert_eq!(res.feature_names.len(), res.values.len());
1258        assert_eq!(res.feature_names[0], "score");
1259        assert_eq!(res.feature_names[1], "color_cat_0");
1260        assert_eq!(res.feature_names[2], "color_cat_1");
1261    }
1262}