Skip to main content

ferrolearn_core/
pipeline.rs

1//! Dynamic-dispatch pipeline for composing transformers and estimators.
2//!
3//! A [`Pipeline`] chains zero or more transformer steps followed by a final
4//! estimator step. Calling [`Fit::fit`] on a pipeline fits each step in
5//! sequence, producing a [`FittedPipeline`] that implements [`Predict`].
6//!
7//! The pipeline is generic over the float type `F`, supporting both `f32`
8//! and `f64` data. All steps in a pipeline must use the same float type.
9//! The type parameter defaults to `f64` for backward compatibility.
10//!
11//! ## REQ status (per `.design/core/pipeline.md`, mirrors `sklearn/pipeline.py` @ 1.5.2)
12//!
13//! ferrolearn's `Pipeline` is a minimal subset of sklearn's: sequential
14//! transformer fit→transform chaining + a single final estimator's fit/predict.
15//!
16//! | REQ | Status | Evidence |
17//! |---|---|---|
18//! | REQ-1 (fit→transform chaining + final predict) | SHIPPED | `Fit for Pipeline` (fit each transformer, transform, fit final estimator) mirrors `Pipeline._fit` (`pipeline.py:406`); `Predict for FittedPipeline` mirrors `Pipeline.predict` (`pipeline.py:599`). Non-test consumers: `impl PipelineEstimator for GaussianNB in gaussian.rs`, `impl PipelineEstimator for BernoulliNB in bernoulli.rs`, `impl PipelineTransformer for KernelPCA in kernel_pca.rs`. (critic: fit-then-transform ≡ sklearn fused fit_transform to ≤1.1e-14 on KernelPCA.) |
19//! | REQ-2 (no-final-estimator rejected at fit) | SHIPPED | `Fit for Pipeline` returns `FerroError::InvalidParameter` when the estimator slot is unset; matches sklearn requiring a final predictor for `.predict` (`available_if` at `pipeline.py:549`). |
20//! | REQ-3 (fit_transform/transform/predict_proba/decision_function/score) | SHIPPED | `Pipeline::fit_transform` (`Fit::fit` then `transform_through`) mirrors `Pipeline.fit_transform` (`pipeline.py:489`); `FittedPipeline::{transform, predict_proba, decision_function, score}` run the private `transform_through` loop (`pipeline.py:599-600`/`:719-720`/`:768-769`/`:999-1000`) then delegate to the final estimator. `predict_proba`/`decision_function`/`score` forward to the new default-`Err` trait methods `predict_proba_pipeline`/`decision_function_pipeline`/`score_pipeline` on `FittedPipelineEstimator` (the `available_if(_final_estimator_has(...))` analog, `pipeline.py:674`/`:731`/`:960`); `transform` returns the transformer-prefix output (sklearn raises `AttributeError` for a non-transformer-final `transform`, `:858`). Non-test consumer: `impl FittedPipelineEstimator for FittedGaussianNBPipeline in gaussian.rs` overrides `predict_proba_pipeline` (→ `predict_proba`) + `score_pipeline` (→ `score`). Live-oracle verification: `gaussian_pipeline_predict_proba_score_match_sklearn` (StandardScaler+GaussianNB pipeline matches sklearn `predict_proba`/`score`/`transform`) + core `test_pipeline_fit_transform_equals_transform`/`test_pipeline_predict_proba_and_score_override`/`test_pipeline_predict_proba_default_is_err`. |
21//! | REQ-4a (named_steps / `__getitem__` int+str+slice) | SHIPPED | `Pipeline::{named_steps, get_step, get_step_by_name, named_step, into_slice}` + `FittedPipeline::{named_steps, get_step, get_step_by_name, named_step}` over the existing `transforms`/`estimator` storage; mirror `Pipeline.named_steps` (`pipeline.py:325` `return Bunch(**dict(self.steps))`), integer/string/slice `Pipeline.__getitem__` (`pipeline.py:298-318`). A step is returned as a `PipelineStepRef`/`FittedPipelineStepRef` enum (the heterogeneous-`(name, obj)`-list analog, since a ferrolearn step is EITHER a `PipelineTransformer` OR a `PipelineEstimator`). `into_slice` consumes `self` (the trait-object steps are not `Clone`, so the new sub-pipeline MOVES the contiguous range, vs sklearn's shallow object-sharing copy `:310`). Non-test consumer: pub API on the grandfathered `Pipeline`/`FittedPipeline` boundary types (S5). Live-oracle verification (R-CHAR-3, sklearn 1.5.2): `test_pipeline_named_steps_match_sklearn`, `test_pipeline_get_step_*`, `test_pipeline_into_slice_*`. |
22//! | REQ-4b (get_params / set_params `<step>__<param>` nested protocol) | NOT-STARTED | blocker #362. The `PipelineTransformer`/`PipelineEstimator` trait objects expose NO `get_params`/reflection method, so the `_BaseComposition._get_params`/`_set_params` nested addressing (`pipeline.py:216`/`:237`) is not implementable without first adding a per-step reflection trait (e.g. `fn get_params(&self) -> BTreeMap<String, ParamValue>` on the step traits). Concrete blocker: a `get_params`/reflection method on the step traits. |
23//! | REQ-5a (passthrough steps) | SHIPPED | `PassthroughTransformer`/`FittedPassthroughTransformer in pipeline.rs` are a reusable identity transformer (`impl PipelineTransformer` `fit_pipeline` → `Box::new(FittedPassthroughTransformer)`; `transform_pipeline(&self, x) → Ok(x.clone())`), the Rust analog of sklearn's `'passthrough'`/`None` step (`sklearn/pipeline.py:251`/`:266` `_validate_steps` allows it, `:275-290` `_iter(filter_passthrough=True)` skips it so `Xt` passes through, `:337` it stays visible in `named_steps`). ferrolearn types the transformer/estimator split, so the no-op IS a concrete identity transformer placed in the chain — no `filter_passthrough` loop branch needed (the step is genuinely identity). Non-test production consumer: the pub `Pipeline::passthrough_step` builder (the `('name','passthrough')` analog, delegating to `transform_step` with a `PassthroughTransformer`), plus the pub API on the `pub mod pipeline` surface (S5 — same boundary as `Pipeline`/`FeatureUnion`, not crate-root re-exported). Live-oracle (R-CHAR-3, sklearn 1.5.2): `Pipeline([('p','passthrough')]).fit(X).transform(X) == X`; passthrough before/after a transformer == the transformer alone; the step appears in `named_steps`/`steps`. Pinned by `test_passthrough_step_is_identity`, `test_passthrough_before_transformer_is_noop`, `test_passthrough_after_transformer_is_noop`, `test_passthrough_step_appears_in_step_names`, `test_passthrough_transformer_standalone_identity`, `test_passthrough_transformer_f32`. |
24//! | REQ-5b (memory caching) | NOT-STARTED | blocker #363. No `memory=`/`check_memory`/`fit_transform_one_cached` transformer caching (`sklearn/pipeline.py:388-390`); requires a joblib disk-cache substrate with no ferrolearn analog yet. |
25//! | REQ-6 (fit_params / metadata routing) | NOT-STARTED | blocker #364. |
26//! | REQ-7 (make_pipeline auto-naming helper) | NOT-STARTED | blocker #365 (`pipeline.py:1220`). |
27//! | REQ-8 (FeatureUnion) | SHIPPED | `FeatureUnion`/`FittedFeatureUnion` in `pipeline.rs`: `impl Fit<Array2<F>, ()> for FeatureUnion` fits each named sub-transformer on the SAME `x` (mirrors `FeatureUnion.fit` fitting every transformer on `X`, `pipeline.py:1643`/`:1681`) recording each output width; the fit also validates transformer-name uniqueness up front (mirrors `_validate_transformers` → `_validate_names`, `pipeline.py:1523-1525` → `sklearn/utils/metaestimators.py:81-83`): a duplicate name returns `FerroError::InvalidParameter` (sklearn's `ValueError: Names provided are not unique` analog) instead of fitting; `impl Transform<Array2<F>> for FittedFeatureUnion` transforms `x` through each and horizontally concatenates the column blocks left-to-right in list order (mirrors `FeatureUnion.transform` → `_hstack`, `pipeline.py:1770`/`:1812` `np.hstack(Xs)`); `FittedFeatureUnion::get_feature_names_out` prefixes each block's positional `x{j}` with `{name}__` (the `verbose_feature_names_out=True` default, `pipeline.py:1567`/`:1608-1616`). Non-test consumer: the pub API on the `pub mod pipeline` surface (S5 — the same boundary the grandfathered `Pipeline`/`FittedPipeline` types live on; neither is crate-root re-exported). Live-oracle (sklearn 1.5.2): `FeatureUnion([('ss',StandardScaler()),('mm',MinMaxScaler())])` on `[[1,2],[3,4],[5,6]]` → `(3,4)` with column blocks `[ss|mm]` and names `['ss__x0','ss__x1','mm__x0','mm__x1']`. NOT-STARTED (no ferrolearn analog yet): `transformer_weights` per-output scaling (`pipeline.py:1369`), the `'drop'`/`'passthrough'` sentinels (`:1530`/`:1563`), `n_jobs` parallelism (`:1360`), metadata routing (`:1859`), `verbose_feature_names_out=False` non-prefixed mode (`:1618-1641`), and the ferray substrate (typed on `ndarray::{Array1,Array2}`). |
28//! | REQ-9 (ferray substrate) | NOT-STARTED | blocker #367 — data flow typed on `ndarray::{Array1,Array2}`; cascades (R-SUBSTRATE-4). |
29//!
30//! acto-critic verdict: NO DIVERGENCE FOUND in the implemented surface (chaining,
31//! y-threading, estimator-only predict, and the REQ-3 apply methods
32//! — `fit_transform`/`transform`/`predict_proba`/`decision_function`/`score` —
33//! all match the live sklearn oracle; `transform` over a non-transformer-final
34//! pipeline returns the transformer-prefix output, the structural analog of
35//! sklearn's `available_if(_can_transform)` `AttributeError`). Two states only
36//! per goal.md R-DEFER-2.
37//!
38//! # Examples
39//!
40//! ```
41//! use ferrolearn_core::pipeline::{Pipeline, PipelineTransformer, PipelineEstimator};
42//! use ferrolearn_core::{Fit, Predict, FerroError};
43//! use ndarray::{Array1, Array2};
44//!
45//! // A trivial identity transformer for demonstration.
46//! struct IdentityTransformer;
47//!
48//! impl PipelineTransformer<f64> for IdentityTransformer {
49//!     fn fit_pipeline(
50//!         &self,
51//!         x: &Array2<f64>,
52//!         _y: &Array1<f64>,
53//!     ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
54//!         Ok(Box::new(FittedIdentity))
55//!     }
56//! }
57//!
58//! struct FittedIdentity;
59//!
60//! impl FittedPipelineTransformer<f64> for FittedIdentity {
61//!     fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
62//!         Ok(x.clone())
63//!     }
64//! }
65//!
66//! // A trivial estimator that predicts the first column.
67//! struct FirstColumnEstimator;
68//!
69//! impl PipelineEstimator<f64> for FirstColumnEstimator {
70//!     fn fit_pipeline(
71//!         &self,
72//!         _x: &Array2<f64>,
73//!         _y: &Array1<f64>,
74//!     ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
75//!         Ok(Box::new(FittedFirstColumn))
76//!     }
77//! }
78//!
79//! struct FittedFirstColumn;
80//!
81//! impl FittedPipelineEstimator<f64> for FittedFirstColumn {
82//!     fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
83//!         Ok(x.column(0).to_owned())
84//!     }
85//! }
86//!
87//! // Build and use the pipeline.
88//! use ferrolearn_core::pipeline::FittedPipelineTransformer;
89//! use ferrolearn_core::pipeline::FittedPipelineEstimator;
90//!
91//! let pipeline = Pipeline::new()
92//!     .transform_step("scaler", Box::new(IdentityTransformer))
93//!     .estimator_step("model", Box::new(FirstColumnEstimator));
94//!
95//! let x = Array2::<f64>::zeros((5, 3));
96//! let y = Array1::<f64>::zeros(5);
97//!
98//! let fitted = pipeline.fit(&x, &y).unwrap();
99//! let preds = fitted.predict(&x).unwrap();
100//! assert_eq!(preds.len(), 5);
101//! ```
102
103use ndarray::{Array1, Array2};
104use num_traits::Float;
105
106use crate::dataset::check_consistent_length;
107use crate::error::FerroError;
108use crate::traits::{Fit, Predict, Transform};
109
110// ---------------------------------------------------------------------------
111// Trait-object interfaces for pipeline steps
112// ---------------------------------------------------------------------------
113
114/// An unfitted transformer step that can participate in a [`Pipeline`].
115///
116/// Implementors must be able to fit themselves on `Array2<F>` data and
117/// return a boxed [`FittedPipelineTransformer`].
118///
119/// The type parameter `F` is the float type (`f32` or `f64`).
120pub trait PipelineTransformer<F: Float + Send + Sync + 'static>: Send + Sync {
121    /// Fit this transformer on the given data.
122    ///
123    /// # Errors
124    ///
125    /// Returns a [`FerroError`] if fitting fails.
126    fn fit_pipeline(
127        &self,
128        x: &Array2<F>,
129        y: &Array1<F>,
130    ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError>;
131}
132
133/// A fitted transformer step in a [`FittedPipeline`].
134///
135/// Transforms `Array2<F>` data, producing a new `Array2<F>`.
136pub trait FittedPipelineTransformer<F: Float + Send + Sync + 'static>: Send + Sync {
137    /// Transform the input data.
138    ///
139    /// # Errors
140    ///
141    /// Returns a [`FerroError`] if the input shape is incompatible.
142    fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError>;
143}
144
145/// An unfitted estimator step that serves as the final step in a [`Pipeline`].
146///
147/// Implementors must be able to fit themselves on `Array2<F>` data and
148/// return a boxed [`FittedPipelineEstimator`].
149pub trait PipelineEstimator<F: Float + Send + Sync + 'static>: Send + Sync {
150    /// Fit this estimator on the given data.
151    ///
152    /// # Errors
153    ///
154    /// Returns a [`FerroError`] if fitting fails.
155    fn fit_pipeline(
156        &self,
157        x: &Array2<F>,
158        y: &Array1<F>,
159    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError>;
160}
161
162/// A fitted estimator step in a [`FittedPipeline`].
163///
164/// Produces `Array1<F>` predictions from `Array2<F>` input.
165///
166/// The three delegating methods below — `predict_proba_pipeline`,
167/// `decision_function_pipeline`, `score_pipeline` — mirror the way sklearn's
168/// `Pipeline` forwards to the final estimator's `predict_proba` /
169/// `decision_function` / `score` (`sklearn/pipeline.py:675`, `:731`, `:961`).
170/// scikit-learn gates each pipeline method on the final estimator actually
171/// having the attribute via `available_if(_final_estimator_has(...))`
172/// (`sklearn/pipeline.py:674`, `:731`, `:960`); a final estimator that lacks
173/// the method raises `AttributeError`. ferrolearn cannot express
174/// `available_if` over a trait object, so each method ships a DEFAULT impl that
175/// returns [`FerroError::InvalidParameter`] (the closest analog of sklearn's
176/// `AttributeError`). A concrete estimator that DOES support the operation
177/// overrides the corresponding method.
178pub trait FittedPipelineEstimator<F: Float + Send + Sync + 'static>: Send + Sync {
179    /// Generate predictions for the input data.
180    ///
181    /// # Errors
182    ///
183    /// Returns a [`FerroError`] if the input shape is incompatible.
184    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError>;
185
186    /// Class-probability estimates for the input data, shape
187    /// `(n_samples, n_classes)`.
188    ///
189    /// Mirrors the final-estimator delegation of `Pipeline.predict_proba`
190    /// (`sklearn/pipeline.py:721`: `self.steps[-1][1].predict_proba(Xt)`).
191    ///
192    /// # Errors
193    ///
194    /// The default implementation returns [`FerroError::InvalidParameter`] —
195    /// the analog of sklearn raising `AttributeError` when the final estimator
196    /// has no `predict_proba`. Estimators that support probability estimates
197    /// override this method.
198    fn predict_proba_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
199        let _ = x;
200        Err(FerroError::InvalidParameter {
201            name: "predict_proba".into(),
202            reason: "the final estimator of this pipeline does not support predict_proba".into(),
203        })
204    }
205
206    /// Confidence scores (decision function) for the input data, shape
207    /// `(n_samples, n_classes)` (or `(n_samples,)` for binary, per the
208    /// estimator's contract).
209    ///
210    /// Mirrors the final-estimator delegation of `Pipeline.decision_function`
211    /// (`sklearn/pipeline.py:772`: `self.steps[-1][1].decision_function(Xt)`).
212    ///
213    /// # Errors
214    ///
215    /// The default implementation returns [`FerroError::InvalidParameter`] —
216    /// the analog of sklearn raising `AttributeError` when the final estimator
217    /// has no `decision_function`. Estimators that expose a decision function
218    /// override this method.
219    fn decision_function_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
220        let _ = x;
221        Err(FerroError::InvalidParameter {
222            name: "decision_function".into(),
223            reason: "the final estimator of this pipeline does not support decision_function"
224                .into(),
225        })
226    }
227
228    /// Score the final estimator on `(x, y)`, returning a single scalar
229    /// (e.g. mean accuracy for a classifier, R² for a regressor).
230    ///
231    /// Mirrors the final-estimator delegation of `Pipeline.score`
232    /// (`sklearn/pipeline.py:1004`: `self.steps[-1][1].score(Xt, y)`).
233    ///
234    /// # Errors
235    ///
236    /// The default implementation returns [`FerroError::InvalidParameter`] —
237    /// the analog of sklearn raising `AttributeError` when the final estimator
238    /// has no `score`. Estimators that support scoring override this method.
239    fn score_pipeline(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
240        let _ = (x, y);
241        Err(FerroError::InvalidParameter {
242            name: "score".into(),
243            reason: "the final estimator of this pipeline does not support score".into(),
244        })
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Pipeline (unfitted)
250// ---------------------------------------------------------------------------
251
252/// A named transformer step in an unfitted pipeline.
253struct TransformStep<F: Float + Send + Sync + 'static> {
254    /// Human-readable name for this step.
255    name: String,
256    /// The unfitted transformer.
257    step: Box<dyn PipelineTransformer<F>>,
258}
259
260/// A borrowed reference to a single step of an unfitted [`Pipeline`].
261///
262/// sklearn's `Pipeline.steps` is a flat list of `(name, obj)` tuples where
263/// every `obj` is duck-typed; `Pipeline.__getitem__` with an integer or string
264/// returns that single `obj` (`sklearn/pipeline.py:298-318`). ferrolearn encodes
265/// the transformer/estimator distinction in the type system, so a "step" is
266/// EITHER a [`PipelineTransformer`] OR a [`PipelineEstimator`]. This enum is the
267/// heterogeneous-step analog: the variant tells the caller which kind of step
268/// they reached, mirroring sklearn returning the underlying object.
269pub enum PipelineStepRef<'a, F: Float + Send + Sync + 'static> {
270    /// A transformer step (an intermediate step of the pipeline).
271    Transformer(&'a dyn PipelineTransformer<F>),
272    /// The final estimator step.
273    Estimator(&'a dyn PipelineEstimator<F>),
274}
275
276/// A dynamic-dispatch pipeline that composes transformers and a final estimator.
277///
278/// Steps are added with [`transform_step`](Pipeline::transform_step) and the
279/// final estimator is set with [`estimator_step`](Pipeline::estimator_step).
280/// The pipeline implements [`Fit<Array2<F>, Array1<F>>`](Fit) and produces
281/// a [`FittedPipeline`] that implements [`Predict<Array2<F>>`](Predict).
282///
283/// All intermediate data flows as `Array2<F>`. The type parameter defaults
284/// to `f64` for backward compatibility.
285pub struct Pipeline<F: Float + Send + Sync + 'static = f64> {
286    /// Ordered transformer steps.
287    transforms: Vec<TransformStep<F>>,
288    /// The final estimator step (name + estimator).
289    estimator: Option<(String, Box<dyn PipelineEstimator<F>>)>,
290}
291
292impl<F: Float + Send + Sync + 'static> Pipeline<F> {
293    /// Create a new empty pipeline.
294    ///
295    /// # Examples
296    ///
297    /// ```
298    /// use ferrolearn_core::pipeline::Pipeline;
299    /// let pipeline = Pipeline::<f64>::new();
300    /// ```
301    pub fn new() -> Self {
302        Self {
303            transforms: Vec::new(),
304            estimator: None,
305        }
306    }
307
308    /// Add a named transformer step to the pipeline.
309    ///
310    /// Transformer steps are applied in the order they are added, before
311    /// the final estimator step.
312    #[must_use]
313    pub fn transform_step(mut self, name: &str, step: Box<dyn PipelineTransformer<F>>) -> Self {
314        self.transforms.push(TransformStep {
315            name: name.to_owned(),
316            step,
317        });
318        self
319    }
320
321    /// Add a named `'passthrough'` (identity no-op) transformer step.
322    ///
323    /// This is the ergonomic analog of an sklearn `('name', 'passthrough')` step:
324    /// a transformer that leaves the running data unchanged but is still a real,
325    /// named step (visible in [`step_names`](Pipeline::step_names) /
326    /// [`named_steps`](Pipeline::named_steps)). It delegates to
327    /// [`transform_step`](Pipeline::transform_step) with a
328    /// [`PassthroughTransformer`], so a passthrough step placed anywhere in the
329    /// chain is a genuine no-op — fitting skips it and transforming passes `Xt`
330    /// through unchanged, mirroring sklearn's `_iter(filter_passthrough=True)`
331    /// dropping `'passthrough'` (`sklearn/pipeline.py:289`) while
332    /// `named_steps`/`__getitem__` still show it (`:337`).
333    #[must_use]
334    pub fn passthrough_step(self, name: &str) -> Self {
335        self.transform_step(name, Box::new(PassthroughTransformer::<F>::new()))
336    }
337
338    /// Set the final estimator step.
339    ///
340    /// A pipeline must have exactly one estimator step. Setting a new
341    /// estimator replaces any previously set estimator.
342    #[must_use]
343    pub fn estimator_step(mut self, name: &str, estimator: Box<dyn PipelineEstimator<F>>) -> Self {
344        self.estimator = Some((name.to_owned(), estimator));
345        self
346    }
347
348    /// Add a named step to the pipeline using the builder pattern.
349    ///
350    /// This is a convenience method that accepts either a transformer or
351    /// an estimator. The final step added via this method that is an
352    /// estimator becomes the pipeline's estimator. This provides the
353    /// `Pipeline::new().step("scaler", ...).step("clf", ...)` API.
354    #[must_use]
355    pub fn step(self, name: &str, step: Box<dyn PipelineStep<F>>) -> Self {
356        step.add_to_pipeline(self, name)
357    }
358
359    /// Fit the pipeline and return both the [`FittedPipeline`] and the data
360    /// after every transformer step has been applied.
361    ///
362    /// This mirrors `Pipeline.fit_transform` (`sklearn/pipeline.py:489-547`):
363    /// `Xt = self._fit(X, y)` fits each transformer on the running `Xt` and
364    /// applies it, then the result is the transformed data. sklearn ALSO calls
365    /// the final estimator's `fit_transform`/`fit().transform()` when the final
366    /// step is itself a transformer (`:540-547`); ferrolearn's final slot is a
367    /// non-transformer estimator, so — like its [`FittedPipeline::transform`] —
368    /// `fit_transform` returns the data after the transformer prefix, with the
369    /// estimator still fit (as in `fit`). The returned `Array2<F>` equals
370    /// [`FittedPipeline::transform`] applied to the same `x` (fit-then-transform
371    /// ≡ sklearn's fused `fit_transform`, established for REQ-1).
372    ///
373    /// # Errors
374    ///
375    /// Returns [`FerroError::InvalidParameter`] if no estimator step was set
376    /// (delegates to [`Fit::fit`]). Propagates any errors from individual step
377    /// fitting or transforming.
378    pub fn fit_transform(
379        &self,
380        x: &Array2<F>,
381        y: &Array1<F>,
382    ) -> Result<(FittedPipeline<F>, Array2<F>), FerroError> {
383        let fitted = self.fit(x, y)?;
384        let transformed = fitted.transform_through(x)?;
385        Ok((fitted, transformed))
386    }
387
388    /// Number of steps in the pipeline (transformer steps plus the final
389    /// estimator, if set).
390    ///
391    /// Mirrors `Pipeline.__len__` (`sklearn/pipeline.py:292-296`:
392    /// `return len(self.steps)`).
393    #[must_use]
394    pub fn len(&self) -> usize {
395        self.transforms.len() + usize::from(self.estimator.is_some())
396    }
397
398    /// Returns `true` if the pipeline has no steps at all.
399    #[must_use]
400    pub fn is_empty(&self) -> bool {
401        self.len() == 0
402    }
403
404    /// Returns the names of all steps (transformers, then the estimator if set)
405    /// in pipeline order.
406    ///
407    /// Mirrors the key ordering of `Pipeline.named_steps`
408    /// (`sklearn/pipeline.py:325`: `Bunch(**dict(self.steps))` keyed by step
409    /// name in `steps` order).
410    #[must_use]
411    pub fn step_names(&self) -> Vec<&str> {
412        let mut names: Vec<&str> = self.transforms.iter().map(|s| s.name.as_str()).collect();
413        if let Some((name, _)) = &self.estimator {
414            names.push(name.as_str());
415        }
416        names
417    }
418
419    /// Access every step by its name, in pipeline order, as a
420    /// `(name, step)` list.
421    ///
422    /// This is the trait-object analog of sklearn's `Pipeline.named_steps`,
423    /// which returns a `Bunch(**dict(self.steps))` — a name→step mapping
424    /// (`sklearn/pipeline.py:325`). Every step (each transformer, then the final
425    /// estimator if set) is reachable by its construction name. ferrolearn
426    /// returns an ordered `Vec` of `(name, PipelineStepRef)` rather than a hash
427    /// map so the pipeline order is preserved and the heterogeneous
428    /// transformer/estimator kinds are distinguishable.
429    #[must_use]
430    pub fn named_steps(&self) -> Vec<(&str, PipelineStepRef<'_, F>)> {
431        let mut steps: Vec<(&str, PipelineStepRef<'_, F>)> = self
432            .transforms
433            .iter()
434            .map(|s| {
435                (
436                    s.name.as_str(),
437                    PipelineStepRef::Transformer(s.step.as_ref()),
438                )
439            })
440            .collect();
441        if let Some((name, est)) = &self.estimator {
442            steps.push((name.as_str(), PipelineStepRef::Estimator(est.as_ref())));
443        }
444        steps
445    }
446
447    /// Look up a single step by name.
448    ///
449    /// This is the string-key arm of sklearn's `Pipeline.__getitem__`
450    /// (`sklearn/pipeline.py:317`: `return self.named_steps[ind]`), which raises
451    /// `KeyError` for an unknown name; ferrolearn returns `None` (R-CODE-2: no
452    /// panic).
453    #[must_use]
454    pub fn named_step(&self, name: &str) -> Option<PipelineStepRef<'_, F>> {
455        if let Some(ts) = self.transforms.iter().find(|s| s.name == name) {
456            return Some(PipelineStepRef::Transformer(ts.step.as_ref()));
457        }
458        match &self.estimator {
459            Some((est_name, est)) if est_name == name => {
460                Some(PipelineStepRef::Estimator(est.as_ref()))
461            }
462            _ => None,
463        }
464    }
465
466    /// Get the step at position `index` (0-based, transformer steps first then
467    /// the final estimator).
468    ///
469    /// This is the integer arm of sklearn's `Pipeline.__getitem__`
470    /// (`sklearn/pipeline.py:313-318`: `name, est = self.steps[ind]; return
471    /// est`), which raises `IndexError` out of range; ferrolearn returns `None`
472    /// (R-CODE-2: no panic).
473    #[must_use]
474    pub fn get_step(&self, index: usize) -> Option<PipelineStepRef<'_, F>> {
475        let n_transforms = self.transforms.len();
476        if index < n_transforms {
477            return Some(PipelineStepRef::Transformer(
478                self.transforms[index].step.as_ref(),
479            ));
480        }
481        if index == n_transforms
482            && let Some((_, est)) = &self.estimator
483        {
484            return Some(PipelineStepRef::Estimator(est.as_ref()));
485        }
486        None
487    }
488
489    /// Look up a single step by name (alias of [`named_step`](Pipeline::named_step)).
490    ///
491    /// Provided for symmetry with [`get_step`](Pipeline::get_step); mirrors the
492    /// string arm of `Pipeline.__getitem__` (`sklearn/pipeline.py:317`).
493    #[must_use]
494    pub fn get_step_by_name(&self, name: &str) -> Option<PipelineStepRef<'_, F>> {
495        self.named_step(name)
496    }
497
498    /// Build a sub-pipeline from the contiguous step range `[start, end)`,
499    /// consuming `self`.
500    ///
501    /// This is the slice arm of sklearn's `Pipeline.__getitem__`
502    /// (`sklearn/pipeline.py:307-312`): `pipe[a:b]` returns
503    /// `Pipeline(self.steps[a:b], ...)` — a new pipeline over the contiguous
504    /// step range. sklearn slicing supports only a step of 1
505    /// (`:308-309`, otherwise `ValueError`); a contiguous Rust range is the step-1
506    /// analog by construction.
507    ///
508    /// The sliced steps are addressed in the unified order
509    /// (transformer steps `0..n_transforms`, then the estimator at
510    /// `n_transforms` if set), matching [`get_step`](Pipeline::get_step). A slice
511    /// that includes the estimator index keeps it as the final estimator; a slice
512    /// of only transformer indices yields an estimator-less pipeline (valid to
513    /// build, errors only at `fit` — mirroring sklearn, where `pipe[:k]` for a
514    /// transformer-only range is a `Pipeline` that simply lacks `.predict`).
515    ///
516    /// # Divergence from sklearn
517    ///
518    /// sklearn's slice is a SHALLOW copy that shares the underlying estimator
519    /// objects with the original pipeline (`sklearn/pipeline.py:303-305`). The
520    /// ferrolearn step trait objects are not `Clone`, so this method MOVES the
521    /// selected boxed steps into the new pipeline and therefore consumes `self`.
522    /// Slicing a [`FittedPipeline`] is NOT implemented for the same reason (the
523    /// fitted step trait objects are not `Clone`); it is NOT-STARTED under
524    /// blocker #362.
525    ///
526    /// Out-of-range bounds CLAMP and `start > end` yields an empty pipeline —
527    /// Python list-slice semantics, mirroring sklearn `Pipeline.__getitem__`'s
528    /// slice arm which slices `self.steps[ind]` (`pipeline.py:307-312`): an
529    /// ordinary Python slice never raises on out-of-range bounds (#2235). So
530    /// `into_slice(0, 100)` on 3 steps → all 3, `into_slice(5, 100)` → empty,
531    /// `into_slice(2, 1)` → empty. This is a TOTAL function (it cannot fail).
532    #[must_use]
533    pub fn into_slice(self, start: usize, end: usize) -> Pipeline<F> {
534        let n_steps = self.len();
535        // Python slice clamping: `end` past the length is clamped to the length;
536        // a `start >= end` (incl. start past the length) yields an empty range
537        // via the `idx >= start && idx < end` filter below.
538        let end = end.min(n_steps);
539
540        let Pipeline {
541            transforms,
542            estimator,
543        } = self;
544        let n_transforms = transforms.len();
545
546        let mut new_transforms = Vec::new();
547        let mut new_estimator = None;
548        for (idx, ts) in transforms.into_iter().enumerate() {
549            if idx >= start && idx < end {
550                new_transforms.push(ts);
551            }
552        }
553        // The estimator (if set) sits at unified index `n_transforms`.
554        if let Some(est) = estimator
555            && n_transforms >= start
556            && n_transforms < end
557        {
558            new_estimator = Some(est);
559        }
560
561        Pipeline {
562            transforms: new_transforms,
563            estimator: new_estimator,
564        }
565    }
566}
567
568impl<F: Float + Send + Sync + 'static> Default for Pipeline<F> {
569    fn default() -> Self {
570        Self::new()
571    }
572}
573
574impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<F>> for Pipeline<F> {
575    type Fitted = FittedPipeline<F>;
576    type Error = FerroError;
577
578    /// Fit the pipeline by fitting each transformer step in order, then
579    /// fitting the final estimator on the transformed data.
580    ///
581    /// Each transformer is fit on the current data, then the data is
582    /// transformed before being passed to the next step.
583    ///
584    /// Before fitting any step, the pipeline validates that `x` and `y` have a
585    /// consistent number of samples via
586    /// [`check_consistent_length`](crate::dataset::check_consistent_length),
587    /// mirroring scikit-learn's `Pipeline.fit`, which runs every step through
588    /// input validation (`check_X_y` → `check_consistent_length`,
589    /// `sklearn/utils/validation.py:1320`) and rejects `X`/`y` with mismatched
590    /// `n_samples` before fitting (`sklearn/pipeline.py:406` `_fit`). A pipeline
591    /// therefore rejects inconsistent `X`/`y` up front rather than failing
592    /// inside a step's `fit_pipeline`.
593    ///
594    /// # Errors
595    ///
596    /// Returns [`FerroError::InvalidParameter`] if no estimator step was set, or
597    /// [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()`. Propagates any
598    /// errors from individual step fitting or transforming.
599    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedPipeline<F>, FerroError> {
600        if self.estimator.is_none() {
601            return Err(FerroError::InvalidParameter {
602                name: "estimator".into(),
603                reason: "pipeline must have a final estimator step".into(),
604            });
605        }
606
607        // sklearn validates X/y sample-count consistency before fitting any
608        // step (`check_consistent_length`, `sklearn/utils/validation.py:1320`).
609        check_consistent_length(x.nrows(), y.len())?;
610
611        let mut current_x = x.clone();
612        let mut fitted_transforms = Vec::with_capacity(self.transforms.len());
613
614        // Fit and transform each transformer step.
615        for ts in &self.transforms {
616            let fitted = ts.step.fit_pipeline(&current_x, y)?;
617            current_x = fitted.transform_pipeline(&current_x)?;
618            fitted_transforms.push(FittedTransformStep {
619                name: ts.name.clone(),
620                step: fitted,
621            });
622        }
623
624        // Fit the final estimator on the transformed data.
625        let (est_name, est) = self.estimator.as_ref().unwrap();
626        let fitted_est = est.fit_pipeline(&current_x, y)?;
627
628        Ok(FittedPipeline {
629            transforms: fitted_transforms,
630            estimator: (est_name.clone(), fitted_est),
631        })
632    }
633}
634
635// ---------------------------------------------------------------------------
636// FittedPipeline
637// ---------------------------------------------------------------------------
638
639/// A named fitted transformer step.
640struct FittedTransformStep<F: Float + Send + Sync + 'static> {
641    /// Human-readable name for this step.
642    name: String,
643    /// The fitted transformer.
644    step: Box<dyn FittedPipelineTransformer<F>>,
645}
646
647/// A borrowed reference to a single step of a [`FittedPipeline`].
648///
649/// The fitted analog of [`PipelineStepRef`]: a fitted step is EITHER a
650/// [`FittedPipelineTransformer`] (an intermediate step) OR the
651/// [`FittedPipelineEstimator`] (the final step). Returned by the
652/// `FittedPipeline` `named_steps` / `get_step` / `named_step` accessors, the
653/// fitted analog of sklearn's `Pipeline.__getitem__` over a fitted pipeline
654/// (`sklearn/pipeline.py:298-318`).
655pub enum FittedPipelineStepRef<'a, F: Float + Send + Sync + 'static> {
656    /// A fitted transformer step.
657    Transformer(&'a dyn FittedPipelineTransformer<F>),
658    /// The fitted final estimator step.
659    Estimator(&'a dyn FittedPipelineEstimator<F>),
660}
661
662/// A fitted pipeline that chains fitted transformers and a fitted estimator.
663///
664/// Created by calling [`Fit::fit`] on a [`Pipeline`]. Implements
665/// [`Predict<Array2<F>>`](Predict), producing `Array1<F>` predictions.
666pub struct FittedPipeline<F: Float + Send + Sync + 'static = f64> {
667    /// Fitted transformer steps, in order.
668    transforms: Vec<FittedTransformStep<F>>,
669    /// The fitted estimator (name + estimator).
670    estimator: (String, Box<dyn FittedPipelineEstimator<F>>),
671}
672
673impl<F: Float + Send + Sync + 'static> FittedPipeline<F> {
674    /// Returns the names of all steps (transformers + estimator) in order.
675    pub fn step_names(&self) -> Vec<&str> {
676        let mut names: Vec<&str> = self.transforms.iter().map(|s| s.name.as_str()).collect();
677        names.push(&self.estimator.0);
678        names
679    }
680
681    /// Number of steps in the fitted pipeline (every transformer step plus the
682    /// final estimator).
683    ///
684    /// Mirrors `Pipeline.__len__` (`sklearn/pipeline.py:292-296`). A
685    /// `FittedPipeline` always has exactly one final estimator (the type
686    /// guarantees it), so this is never zero.
687    #[must_use]
688    pub fn len(&self) -> usize {
689        self.transforms.len() + 1
690    }
691
692    /// Always `false`: a fitted pipeline always has at least its final
693    /// estimator step.
694    #[must_use]
695    pub fn is_empty(&self) -> bool {
696        false
697    }
698
699    /// Access every fitted step by its name, in pipeline order, as a
700    /// `(name, step)` list.
701    ///
702    /// The fitted analog of sklearn's `Pipeline.named_steps`
703    /// (`sklearn/pipeline.py:325`: `Bunch(**dict(self.steps))`) — every fitted
704    /// step (each transformer, then the final estimator) is reachable by its
705    /// construction name, in pipeline order.
706    #[must_use]
707    pub fn named_steps(&self) -> Vec<(&str, FittedPipelineStepRef<'_, F>)> {
708        let mut steps: Vec<(&str, FittedPipelineStepRef<'_, F>)> = self
709            .transforms
710            .iter()
711            .map(|s| {
712                (
713                    s.name.as_str(),
714                    FittedPipelineStepRef::Transformer(s.step.as_ref()),
715                )
716            })
717            .collect();
718        steps.push((
719            self.estimator.0.as_str(),
720            FittedPipelineStepRef::Estimator(self.estimator.1.as_ref()),
721        ));
722        steps
723    }
724
725    /// Look up a single fitted step by name.
726    ///
727    /// The fitted analog of the string arm of `Pipeline.__getitem__`
728    /// (`sklearn/pipeline.py:317`); returns `None` for an unknown name (R-CODE-2:
729    /// no panic, vs sklearn's `KeyError`).
730    #[must_use]
731    pub fn named_step(&self, name: &str) -> Option<FittedPipelineStepRef<'_, F>> {
732        if let Some(ts) = self.transforms.iter().find(|s| s.name == name) {
733            return Some(FittedPipelineStepRef::Transformer(ts.step.as_ref()));
734        }
735        if self.estimator.0 == name {
736            return Some(FittedPipelineStepRef::Estimator(self.estimator.1.as_ref()));
737        }
738        None
739    }
740
741    /// Get the fitted step at position `index` (0-based, transformer steps
742    /// first then the final estimator).
743    ///
744    /// The fitted analog of the integer arm of `Pipeline.__getitem__`
745    /// (`sklearn/pipeline.py:313-318`); returns `None` out of range (R-CODE-2: no
746    /// panic, vs sklearn's `IndexError`).
747    #[must_use]
748    pub fn get_step(&self, index: usize) -> Option<FittedPipelineStepRef<'_, F>> {
749        let n_transforms = self.transforms.len();
750        if index < n_transforms {
751            return Some(FittedPipelineStepRef::Transformer(
752                self.transforms[index].step.as_ref(),
753            ));
754        }
755        if index == n_transforms {
756            return Some(FittedPipelineStepRef::Estimator(self.estimator.1.as_ref()));
757        }
758        None
759    }
760
761    /// Look up a single fitted step by name (alias of
762    /// [`named_step`](FittedPipeline::named_step)).
763    ///
764    /// Mirrors the string arm of `Pipeline.__getitem__`
765    /// (`sklearn/pipeline.py:317`).
766    #[must_use]
767    pub fn get_step_by_name(&self, name: &str) -> Option<FittedPipelineStepRef<'_, F>> {
768        self.named_step(name)
769    }
770
771    /// Run `x` through every fitted transformer step in order, returning the
772    /// fully transformed data (the data the final estimator sees).
773    ///
774    /// This is the shared `for ...: Xt = transform.transform(Xt)` loop of
775    /// sklearn's `Pipeline.predict` / `predict_proba` / `decision_function` /
776    /// `score` (`sklearn/pipeline.py:599-600`, `:719-720`, `:768-769`,
777    /// `:999-1000`), which run the data through every non-final transformer
778    /// before delegating to the final estimator.
779    ///
780    /// # Errors
781    ///
782    /// Propagates any [`FerroError`] from an individual transformer step.
783    fn transform_through(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
784        let mut current_x = x.clone();
785        for ts in &self.transforms {
786            current_x = ts.step.transform_pipeline(&current_x)?;
787        }
788        Ok(current_x)
789    }
790
791    /// Apply every fitted transformer step to `x`, returning the transformed
792    /// data without invoking the final estimator.
793    ///
794    /// This mirrors `Pipeline.transform` (`sklearn/pipeline.py:863-904`) for the
795    /// *transformer-final* case. sklearn gates `transform` on
796    /// `_can_transform` (`:858`): it is only available when the final step is
797    /// itself a transformer, in which case it runs the data through ALL steps
798    /// including the last (`for _, name, transform in self._iter(): Xt =
799    /// transform.transform(Xt)`). When the final step is a non-transformer
800    /// estimator (e.g. `GaussianNB`), sklearn raises `AttributeError`
801    /// (`'Pipeline' has no attribute 'transform'`, verified against the live
802    /// 1.5.2 oracle).
803    ///
804    /// ferrolearn's [`FittedPipeline`] structurally separates the transformer
805    /// steps from a single non-transformer estimator slot (the estimator is
806    /// reached via [`predict_pipeline`](FittedPipelineEstimator::predict_pipeline),
807    /// not `transform_pipeline`). Therefore `transform` applies exactly the
808    /// transformer steps and returns the data the final estimator would see —
809    /// equivalent to sklearn's transformer-final `transform` over the
810    /// transformer prefix. The estimator slot is never a transformer, so there
811    /// is no "transform the final step too" branch to mirror.
812    ///
813    /// # Errors
814    ///
815    /// Propagates any [`FerroError`] from a transformer step (e.g. a feature
816    /// count mismatch).
817    pub fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
818        self.transform_through(x)
819    }
820
821    /// Transform `x` through every fitted transformer step, then return the
822    /// final estimator's class-probability estimates, shape
823    /// `(n_samples, n_classes)`.
824    ///
825    /// Mirrors `Pipeline.predict_proba` (`sklearn/pipeline.py:716-721`): run the
826    /// data through every non-final transformer, then
827    /// `self.steps[-1][1].predict_proba(Xt)`.
828    ///
829    /// # Errors
830    ///
831    /// Propagates transformer-step errors; returns [`FerroError::InvalidParameter`]
832    /// if the final estimator does not support `predict_proba` (sklearn's
833    /// `AttributeError` analog).
834    pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
835        let xt = self.transform_through(x)?;
836        self.estimator.1.predict_proba_pipeline(&xt)
837    }
838
839    /// Transform `x` through every fitted transformer step, then return the
840    /// final estimator's decision-function scores.
841    ///
842    /// Mirrors `Pipeline.decision_function` (`sklearn/pipeline.py:767-774`): run
843    /// the data through every non-final transformer, then
844    /// `self.steps[-1][1].decision_function(Xt)`.
845    ///
846    /// # Errors
847    ///
848    /// Propagates transformer-step errors; returns [`FerroError::InvalidParameter`]
849    /// if the final estimator does not support `decision_function` (sklearn's
850    /// `AttributeError` analog).
851    pub fn decision_function(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
852        let xt = self.transform_through(x)?;
853        self.estimator.1.decision_function_pipeline(&xt)
854    }
855
856    /// Transform `x` through every fitted transformer step, then return the
857    /// final estimator's score on `(Xt, y)` (e.g. mean accuracy for a
858    /// classifier).
859    ///
860    /// Mirrors `Pipeline.score` (`sklearn/pipeline.py:997-1004`): run the data
861    /// through every non-final transformer, then
862    /// `self.steps[-1][1].score(Xt, y)`. ferrolearn does not yet thread
863    /// `sample_weight` (sklearn's optional third argument, `:961`); that is part
864    /// of the metadata-routing surface (REQ-6, blocker #364).
865    ///
866    /// # Errors
867    ///
868    /// Propagates transformer-step errors; returns [`FerroError::InvalidParameter`]
869    /// if the final estimator does not support `score` (sklearn's
870    /// `AttributeError` analog).
871    pub fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
872        let xt = self.transform_through(x)?;
873        self.estimator.1.score_pipeline(&xt, y)
874    }
875}
876
877impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedPipeline<F> {
878    type Output = Array1<F>;
879    type Error = FerroError;
880
881    /// Generate predictions by transforming the input through each fitted
882    /// transformer step, then calling predict on the fitted estimator.
883    ///
884    /// # Errors
885    ///
886    /// Propagates any errors from transformer or estimator steps.
887    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
888        let current_x = self.transform_through(x)?;
889        self.estimator.1.predict_pipeline(&current_x)
890    }
891}
892
893// ---------------------------------------------------------------------------
894// PipelineStep: unified interface for the `.step()` builder method
895// ---------------------------------------------------------------------------
896
897/// A trait that unifies transformers and estimators for the
898/// [`Pipeline::step`] builder method.
899///
900/// Implementors of [`PipelineTransformer`] and [`PipelineEstimator`]
901/// automatically get a blanket implementation of this trait via the
902/// wrapper types [`TransformerStepWrapper`] and [`EstimatorStepWrapper`].
903///
904/// For convenience, use [`as_transform_step`] and [`as_estimator_step`]
905/// to wrap your types.
906pub trait PipelineStep<F: Float + Send + Sync + 'static>: Send + Sync {
907    /// Add this step to the pipeline under the given name.
908    ///
909    /// Transformer steps are added as intermediate transform steps.
910    /// Estimator steps are set as the final estimator.
911    fn add_to_pipeline(self: Box<Self>, pipeline: Pipeline<F>, name: &str) -> Pipeline<F>;
912}
913
914/// Wraps a [`PipelineTransformer`] to implement [`PipelineStep`].
915///
916/// Created by [`as_transform_step`].
917pub struct TransformerStepWrapper<F: Float + Send + Sync + 'static>(
918    Box<dyn PipelineTransformer<F>>,
919);
920
921impl<F: Float + Send + Sync + 'static> PipelineStep<F> for TransformerStepWrapper<F> {
922    fn add_to_pipeline(self: Box<Self>, pipeline: Pipeline<F>, name: &str) -> Pipeline<F> {
923        pipeline.transform_step(name, self.0)
924    }
925}
926
927/// Wraps a [`PipelineEstimator`] to implement [`PipelineStep`].
928///
929/// Created by [`as_estimator_step`].
930pub struct EstimatorStepWrapper<F: Float + Send + Sync + 'static>(Box<dyn PipelineEstimator<F>>);
931
932impl<F: Float + Send + Sync + 'static> PipelineStep<F> for EstimatorStepWrapper<F> {
933    fn add_to_pipeline(self: Box<Self>, pipeline: Pipeline<F>, name: &str) -> Pipeline<F> {
934        pipeline.estimator_step(name, self.0)
935    }
936}
937
938/// Wrap a [`PipelineTransformer`] as a [`PipelineStep`] for use with
939/// [`Pipeline::step`].
940///
941/// # Examples
942///
943/// ```
944/// use ferrolearn_core::pipeline::{Pipeline, as_transform_step};
945/// // Assuming `my_scaler` implements PipelineTransformer<f64>:
946/// // let pipeline = Pipeline::new().step("scaler", as_transform_step(my_scaler));
947/// ```
948pub fn as_transform_step<F: Float + Send + Sync + 'static>(
949    t: impl PipelineTransformer<F> + 'static,
950) -> Box<dyn PipelineStep<F>> {
951    Box::new(TransformerStepWrapper(Box::new(t)))
952}
953
954/// Wrap a [`PipelineEstimator`] as a [`PipelineStep`] for use with
955/// [`Pipeline::step`].
956///
957/// # Examples
958///
959/// ```
960/// use ferrolearn_core::pipeline::{Pipeline, as_estimator_step};
961/// // Assuming `my_model` implements PipelineEstimator<f64>:
962/// // let pipeline = Pipeline::new().step("model", as_estimator_step(my_model));
963/// ```
964pub fn as_estimator_step<F: Float + Send + Sync + 'static>(
965    e: impl PipelineEstimator<F> + 'static,
966) -> Box<dyn PipelineStep<F>> {
967    Box::new(EstimatorStepWrapper(Box::new(e)))
968}
969
970// ---------------------------------------------------------------------------
971// PassthroughTransformer: the `'passthrough'` step analog (identity no-op)
972// ---------------------------------------------------------------------------
973
974/// A no-op transformer step: fit does nothing and transform returns its input
975/// unchanged.
976///
977/// This is the ferrolearn analog of scikit-learn's `'passthrough'` (and `None`)
978/// pipeline step. In sklearn, a `Pipeline` step whose object is the string
979/// `'passthrough'` (or `None`) is a transformer that is *skipped* during
980/// fit/transform — `_iter(filter_passthrough=True)` drops it
981/// (`sklearn/pipeline.py:275-290`), so the running `Xt` passes through unchanged
982/// — yet the step is still visible in `named_steps` / `steps` / `__getitem__`
983/// (`sklearn/pipeline.py:337`: `"passthrough" if estimator is None else
984/// estimator`). The net behavior is identity: `Pipeline([('p','passthrough')])
985/// .fit(X).transform(X) == X` (verified against the live 1.5.2 oracle).
986///
987/// ferrolearn encodes the transformer/estimator distinction in the type system
988/// (there is no untyped `steps` list to hold a sentinel string), so rather than a
989/// `filter_passthrough` branch in the fit/transform loop, the passthrough step is
990/// a concrete, reusable *identity transformer*: its `fit_pipeline` is a no-op and
991/// its [`FittedPassthroughTransformer::transform_pipeline`] returns `x.clone()`.
992/// Placed anywhere in a [`Pipeline`] it leaves the running data unchanged and
993/// still appears in [`Pipeline::step_names`] / [`Pipeline::named_steps`], exactly
994/// matching sklearn's observable contract. The ergonomic builder
995/// [`Pipeline::passthrough_step`] adds one under a given name (the `('name',
996/// 'passthrough')` analog).
997///
998/// The type parameter `F` is the float type (`f32` or `f64`), defaulting to
999/// `f64` to match the rest of this module.
1000///
1001/// # Examples
1002///
1003/// ```
1004/// use ferrolearn_core::pipeline::{PassthroughTransformer, FittedPipelineTransformer};
1005/// use ferrolearn_core::pipeline::PipelineTransformer;
1006/// use ndarray::{Array1, Array2};
1007///
1008/// let p = PassthroughTransformer::<f64>::new();
1009/// let x = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1010/// let y = Array1::<f64>::zeros(2);
1011/// let fitted = p.fit_pipeline(&x, &y).unwrap();
1012/// // Identity: transform returns the input unchanged.
1013/// assert_eq!(fitted.transform_pipeline(&x).unwrap(), x);
1014/// ```
1015pub struct PassthroughTransformer<F: Float + Send + Sync + 'static = f64> {
1016    /// `PassthroughTransformer` holds no state; the marker ties the no-op to the
1017    /// float type `F` so it slots into an `F`-typed [`Pipeline`].
1018    _marker: core::marker::PhantomData<F>,
1019}
1020
1021impl<F: Float + Send + Sync + 'static> PassthroughTransformer<F> {
1022    /// Create a new passthrough (identity) transformer.
1023    ///
1024    /// # Examples
1025    ///
1026    /// ```
1027    /// use ferrolearn_core::pipeline::PassthroughTransformer;
1028    /// let p = PassthroughTransformer::<f64>::new();
1029    /// ```
1030    #[must_use]
1031    pub fn new() -> Self {
1032        Self {
1033            _marker: core::marker::PhantomData,
1034        }
1035    }
1036}
1037
1038impl<F: Float + Send + Sync + 'static> Default for PassthroughTransformer<F> {
1039    fn default() -> Self {
1040        Self::new()
1041    }
1042}
1043
1044impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for PassthroughTransformer<F> {
1045    /// Fitting a passthrough step does nothing (there are no parameters to learn);
1046    /// it yields a [`FittedPassthroughTransformer`] whose transform is the
1047    /// identity. Mirrors sklearn skipping a `'passthrough'` step at fit
1048    /// (`_iter(filter_passthrough=True)`, `sklearn/pipeline.py:289`), so the
1049    /// running `Xt` is unaffected.
1050    fn fit_pipeline(
1051        &self,
1052        _x: &Array2<F>,
1053        _y: &Array1<F>,
1054    ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
1055        Ok(Box::new(FittedPassthroughTransformer::new()))
1056    }
1057}
1058
1059/// The fitted half of a [`PassthroughTransformer`]: an identity transform.
1060///
1061/// [`transform_pipeline`](FittedPassthroughTransformer::transform_pipeline)
1062/// returns its input unchanged, the fitted analog of sklearn's skipped
1063/// `'passthrough'` step leaving the running `Xt` unchanged
1064/// (`sklearn/pipeline.py:275-290`).
1065pub struct FittedPassthroughTransformer<F: Float + Send + Sync + 'static = f64> {
1066    /// No fitted state; the marker ties the identity transform to `F`.
1067    _marker: core::marker::PhantomData<F>,
1068}
1069
1070impl<F: Float + Send + Sync + 'static> FittedPassthroughTransformer<F> {
1071    /// Create a new fitted passthrough (identity) transformer.
1072    #[must_use]
1073    pub fn new() -> Self {
1074        Self {
1075            _marker: core::marker::PhantomData,
1076        }
1077    }
1078}
1079
1080impl<F: Float + Send + Sync + 'static> Default for FittedPassthroughTransformer<F> {
1081    fn default() -> Self {
1082        Self::new()
1083    }
1084}
1085
1086impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F>
1087    for FittedPassthroughTransformer<F>
1088{
1089    /// Return the input unchanged (identity).
1090    ///
1091    /// This is the no-op that makes a passthrough step transparent: the data the
1092    /// next step (or final estimator) sees is exactly what entered. Matches
1093    /// sklearn's `'passthrough'` net behavior `Pipeline([('p','passthrough')])
1094    /// .transform(X) == X` (live 1.5.2 oracle).
1095    fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1096        Ok(x.clone())
1097    }
1098}
1099
1100// ---------------------------------------------------------------------------
1101// FeatureUnion (unfitted)
1102// ---------------------------------------------------------------------------
1103
1104/// A composite transformer that fits multiple named sub-transformers on the
1105/// SAME input and horizontally concatenates their outputs.
1106///
1107/// This is the ferrolearn analog of scikit-learn's `sklearn.pipeline.FeatureUnion`
1108/// (`sklearn/pipeline.py:1329`). Where a [`Pipeline`] chains transformers
1109/// *sequentially* (each transformer sees the previous one's output),
1110/// `FeatureUnion` applies every transformer *in parallel* to the same `X`, then
1111/// concatenates the results column-wise: the output width is the sum of each
1112/// sub-transformer's output width, and the columns appear left-to-right in the
1113/// order the transformers were added (mirrors `FeatureUnion.transform` →
1114/// `_hstack` `np.hstack(Xs)`, `sklearn/pipeline.py:1770`/`:1812`).
1115///
1116/// `FeatureUnion` reuses the [`PipelineTransformer`] / [`FittedPipelineTransformer`]
1117/// trait objects already used by [`Pipeline`], so any transformer usable in a
1118/// pipeline is usable in a feature union.
1119///
1120/// The type parameter `F` is the float type (`f32` or `f64`), defaulting to
1121/// `f64` to match the rest of this module.
1122///
1123/// # Divergence from scikit-learn
1124///
1125/// This is the core fit / transform / hstack / `get_feature_names_out` subset.
1126/// `transformer_weights` (per-transformer output scaling,
1127/// `sklearn/pipeline.py:1369`), the `'drop'` / `'passthrough'` sentinels
1128/// (`:1530`/`:1563`), `n_jobs` parallelism (`:1360`), metadata routing (`:1859`),
1129/// and `verbose_feature_names_out=False` (`:1618`) are NOT implemented (REQ-8
1130/// NOT-STARTED scope). The data substrate is `ndarray`, not yet ferray.
1131///
1132/// # Examples
1133///
1134/// ```
1135/// use ferrolearn_core::pipeline::{
1136///     FeatureUnion, PipelineTransformer, FittedPipelineTransformer,
1137/// };
1138/// use ferrolearn_core::{Transform, FerroError};
1139/// use ndarray::{Array1, Array2};
1140///
1141/// // A transformer that returns its input unchanged.
1142/// struct Identity;
1143/// impl PipelineTransformer<f64> for Identity {
1144///     fn fit_pipeline(
1145///         &self,
1146///         _x: &Array2<f64>,
1147///         _y: &Array1<f64>,
1148///     ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
1149///         Ok(Box::new(FittedIdentity))
1150///     }
1151/// }
1152/// struct FittedIdentity;
1153/// impl FittedPipelineTransformer<f64> for FittedIdentity {
1154///     fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
1155///         Ok(x.clone())
1156///     }
1157/// }
1158///
1159/// use ferrolearn_core::Fit;
1160/// let union = FeatureUnion::<f64>::new()
1161///     .with_transformer("a", Box::new(Identity))
1162///     .with_transformer("b", Box::new(Identity));
1163/// let x = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1164/// let fitted = union.fit(&x, &()).unwrap();
1165/// // Two identity transformers → output width 2 + 2 = 4.
1166/// let out = fitted.transform(&x).unwrap();
1167/// assert_eq!(out.dim(), (2, 4));
1168/// assert_eq!(fitted.get_feature_names_out(), vec!["a__x0", "a__x1", "b__x0", "b__x1"]);
1169/// ```
1170pub struct FeatureUnion<F: Float + Send + Sync + 'static = f64> {
1171    /// Ordered named transformers, all fit on the same input.
1172    transformer_list: Vec<(String, Box<dyn PipelineTransformer<F>>)>,
1173}
1174
1175impl<F: Float + Send + Sync + 'static> FeatureUnion<F> {
1176    /// Create a new empty feature union.
1177    ///
1178    /// Sub-transformers are added with
1179    /// [`with_transformer`](FeatureUnion::with_transformer). An empty union fits
1180    /// successfully and transforms to a `(n_samples, 0)` matrix (the empty
1181    /// `np.hstack` analog).
1182    ///
1183    /// # Examples
1184    ///
1185    /// ```
1186    /// use ferrolearn_core::pipeline::FeatureUnion;
1187    /// let union = FeatureUnion::<f64>::new();
1188    /// assert_eq!(union.n_transformers(), 0);
1189    /// ```
1190    #[must_use]
1191    pub fn new() -> Self {
1192        Self {
1193            transformer_list: Vec::new(),
1194        }
1195    }
1196
1197    /// Add a named transformer to the union using the builder pattern.
1198    ///
1199    /// Mirrors an entry of sklearn's `transformer_list`
1200    /// (`sklearn/pipeline.py:1348`). Transformers are applied in the order they
1201    /// are added; their outputs are concatenated left-to-right.
1202    #[must_use]
1203    pub fn with_transformer(mut self, name: &str, t: Box<dyn PipelineTransformer<F>>) -> Self {
1204        self.transformer_list.push((name.to_owned(), t));
1205        self
1206    }
1207
1208    /// Returns the names of all sub-transformers, in union order.
1209    ///
1210    /// Mirrors the key order of sklearn's `named_transformers`
1211    /// (`sklearn/pipeline.py:1478`: `Bunch(**dict(self.transformer_list))`).
1212    #[must_use]
1213    pub fn transformer_names(&self) -> Vec<&str> {
1214        self.transformer_list
1215            .iter()
1216            .map(|(name, _)| name.as_str())
1217            .collect()
1218    }
1219
1220    /// Number of sub-transformers in the union.
1221    #[must_use]
1222    pub fn n_transformers(&self) -> usize {
1223        self.transformer_list.len()
1224    }
1225}
1226
1227impl<F: Float + Send + Sync + 'static> Default for FeatureUnion<F> {
1228    fn default() -> Self {
1229        Self::new()
1230    }
1231}
1232
1233impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, ()> for FeatureUnion<F> {
1234    type Fitted = FittedFeatureUnion<F>;
1235    type Error = FerroError;
1236
1237    /// Fit every sub-transformer on the SAME input `x`.
1238    ///
1239    /// Mirrors `FeatureUnion.fit` (`sklearn/pipeline.py:1643`), which fits each
1240    /// transformer in `transformer_list` independently on the full `X` (every
1241    /// transformer sees the same data, unlike the sequential `Pipeline`). The
1242    /// per-transformer output width is recorded at fit time (by transforming `x`
1243    /// once) so that `get_feature_names_out` can size each column block.
1244    ///
1245    /// # `y` handling
1246    ///
1247    /// sklearn's `FeatureUnion` threads `y` to each sub-transformer's `fit`
1248    /// (`sklearn/pipeline.py:1681`/`_fit_one`), but feature-union transformers are
1249    /// unsupervised and ignore it. ferrolearn's [`PipelineTransformer::fit_pipeline`]
1250    /// requires an `Array1<F>` target, so this impl passes an empty
1251    /// `Array1::zeros(0)` — the union's own `Fit` target type is `()` (it takes no
1252    /// supervised target), and the empty array is the no-target sentinel handed to
1253    /// each unsupervised sub-transformer.
1254    ///
1255    /// # Errors
1256    ///
1257    /// Propagates any [`FerroError`] from an individual sub-transformer's
1258    /// `fit_pipeline` or its width-probing `transform_pipeline`.
1259    fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedFeatureUnion<F>, FerroError> {
1260        // Validate transformer-name uniqueness BEFORE fitting any sub-transformer,
1261        // mirroring `FeatureUnion._validate_transformers` → `_validate_names`
1262        // (`sklearn/pipeline.py:1523-1525` → `sklearn/utils/metaestimators.py:81-83`),
1263        // which sklearn runs on every fit/fit_transform: `if len(set(names)) !=
1264        // len(names): raise ValueError("Names provided are not unique: {names!r}")`.
1265        // R-DEV-2 (user-API ABI / exception parity): a duplicate name is a
1266        // deliberate `ValueError`, so ferrolearn rejects it at fit with the
1267        // closest analog, `FerroError::InvalidParameter`.
1268        let names: Vec<&str> = self
1269            .transformer_list
1270            .iter()
1271            .map(|(name, _)| name.as_str())
1272            .collect();
1273        let mut seen = std::collections::HashSet::with_capacity(names.len());
1274        if !names.iter().all(|name| seen.insert(*name)) {
1275            return Err(FerroError::InvalidParameter {
1276                name: "transformer_list".into(),
1277                reason: format!("Names provided are not unique: {names:?}"),
1278            });
1279        }
1280
1281        // Reject any name containing the reserved `__` separator, mirroring the
1282        // THIRD clause of `_validate_names`
1283        // (`sklearn/utils/metaestimators.py:91-95`): `invalid_names = [name for
1284        // name in names if "__" in name]; if invalid_names: raise
1285        // ValueError("Estimator names must not contain __: got {0!r}")`. `__` is
1286        // reserved for the nested-parameter addressing protocol
1287        // (`<step>__<param>`), so it is forbidden anywhere in a step name (a
1288        // single `_` is fine). sklearn runs this AFTER the uniqueness clause; we
1289        // match that order. R-DEV-2 (exception parity): a deliberate `ValueError`,
1290        // mapped to the closest analog `FerroError::InvalidParameter`. (The MIDDLE
1291        // clause — names colliding with constructor-arg params,
1292        // `metaestimators.py:84-90` — has no ferrolearn analog: `FeatureUnion`
1293        // exposes no `get_params` params, so it is intentionally not mirrored.)
1294        let invalid_names: Vec<&str> = names
1295            .iter()
1296            .copied()
1297            .filter(|name| name.contains("__"))
1298            .collect();
1299        if !invalid_names.is_empty() {
1300            return Err(FerroError::InvalidParameter {
1301                name: "transformer_list".into(),
1302                reason: format!("Estimator names must not contain __: got {invalid_names:?}"),
1303            });
1304        }
1305
1306        // FeatureUnion sub-transformers are unsupervised; sklearn passes `y`
1307        // through but the transformers ignore it (`sklearn/pipeline.py:1681`).
1308        // The empty target is the no-supervision sentinel for `fit_pipeline`.
1309        let empty_y: Array1<F> = Array1::zeros(0);
1310
1311        let mut fitted = Vec::with_capacity(self.transformer_list.len());
1312        let mut n_features_per = Vec::with_capacity(self.transformer_list.len());
1313
1314        for (name, transformer) in &self.transformer_list {
1315            let fitted_t = transformer.fit_pipeline(x, &empty_y)?;
1316            // Probe the output width once at fit so feature-name prefixing and
1317            // the hstack column layout know each block's size.
1318            let out = fitted_t.transform_pipeline(x)?;
1319            n_features_per.push(out.ncols());
1320            fitted.push((name.clone(), fitted_t));
1321        }
1322
1323        Ok(FittedFeatureUnion {
1324            fitted,
1325            n_features_per,
1326        })
1327    }
1328}
1329
1330// ---------------------------------------------------------------------------
1331// FittedFeatureUnion
1332// ---------------------------------------------------------------------------
1333
1334/// A fitted [`FeatureUnion`]: each named sub-transformer is fitted, and the
1335/// per-transformer output width is recorded for feature-name prefixing and the
1336/// horizontal-concatenation column layout.
1337///
1338/// Created by calling [`Fit::fit`] on a [`FeatureUnion`]. Implements
1339/// [`Transform<Array2<F>>`](Transform) producing the horizontally concatenated
1340/// `Array2<F>`.
1341pub struct FittedFeatureUnion<F: Float + Send + Sync + 'static = f64> {
1342    /// Fitted sub-transformers, in union order.
1343    fitted: Vec<(String, Box<dyn FittedPipelineTransformer<F>>)>,
1344    /// The output column count of each sub-transformer, in union order
1345    /// (recorded at fit). The total output width is the sum of these.
1346    n_features_per: Vec<usize>,
1347}
1348
1349impl<F: Float + Send + Sync + 'static> FittedFeatureUnion<F> {
1350    /// Returns the names of all fitted sub-transformers, in union order.
1351    #[must_use]
1352    pub fn transformer_names(&self) -> Vec<&str> {
1353        self.fitted.iter().map(|(name, _)| name.as_str()).collect()
1354    }
1355
1356    /// Number of fitted sub-transformers in the union.
1357    #[must_use]
1358    pub fn n_transformers(&self) -> usize {
1359        self.fitted.len()
1360    }
1361
1362    /// Total output width: the sum of every sub-transformer's output column
1363    /// count. Equals the number of columns in [`Transform::transform`]'s output.
1364    #[must_use]
1365    pub fn n_features_out(&self) -> usize {
1366        self.n_features_per.iter().sum()
1367    }
1368
1369    /// Output feature names, one per output column, in concatenation order.
1370    ///
1371    /// For each sub-transformer named `name` with output width `w`, this emits
1372    /// `"{name}__x0" .. "{name}__x{w-1}"`, then moves on to the next
1373    /// transformer's block. This mirrors `FeatureUnion.get_feature_names_out`
1374    /// with the default `verbose_feature_names_out=True`
1375    /// (`sklearn/pipeline.py:1567`/`:1608-1616`): sklearn prefixes each
1376    /// sub-transformer's own feature name with `"{name}__"`.
1377    ///
1378    /// ferrolearn's [`PipelineTransformer`] trait objects do not expose their own
1379    /// per-output feature names, so the positional default `x{j}` is used as the
1380    /// suffix — this is sklearn's `OneToOneFeatureMixin` positional default
1381    /// (`['x0','x1',...]`), which is exactly what `StandardScaler` /
1382    /// `MinMaxScaler` and other column-preserving transformers produce. So a union
1383    /// of two such transformers named `ss`/`mm` over 2-column input yields
1384    /// `['ss__x0','ss__x1','mm__x0','mm__x1']`, matching the live oracle.
1385    #[must_use]
1386    pub fn get_feature_names_out(&self) -> Vec<String> {
1387        let mut names = Vec::with_capacity(self.n_features_out());
1388        for ((name, _), &width) in self.fitted.iter().zip(self.n_features_per.iter()) {
1389            for j in 0..width {
1390                names.push(format!("{name}__x{j}"));
1391            }
1392        }
1393        names
1394    }
1395}
1396
1397impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedFeatureUnion<F> {
1398    type Output = Array2<F>;
1399    type Error = FerroError;
1400
1401    /// Transform `x` through every fitted sub-transformer and horizontally
1402    /// concatenate the results.
1403    ///
1404    /// Mirrors `FeatureUnion.transform` (`sklearn/pipeline.py:1770`): each
1405    /// transformer transforms the same `x`, then `self._hstack(Xs)`
1406    /// (`np.hstack`, `:1812`/`:1820`) concatenates the outputs column-wise. The
1407    /// output has shape `(n_samples, sum_of_widths)` and the columns appear in
1408    /// transformer order: block 0 is the first transformer's full output, block 1
1409    /// the second's, and so on. An empty union transforms to a `(n_samples, 0)`
1410    /// matrix (the empty-`np.hstack` analog).
1411    ///
1412    /// # Errors
1413    ///
1414    /// Propagates any [`FerroError`] from an individual sub-transformer. Returns
1415    /// [`FerroError::ShapeMismatch`] if a sub-transformer's output does not have
1416    /// `n_samples == x.nrows()` rows (the hstack requires row-aligned blocks).
1417    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1418        let n_rows = x.nrows();
1419
1420        // Transform `x` through each sub-transformer, collecting the blocks and
1421        // their widths. Validate each block is row-aligned before any copy.
1422        let mut blocks: Vec<Array2<F>> = Vec::with_capacity(self.fitted.len());
1423        let mut total_width = 0usize;
1424        for (name, transformer) in &self.fitted {
1425            let block = transformer.transform_pipeline(x)?;
1426            if block.nrows() != n_rows {
1427                return Err(FerroError::ShapeMismatch {
1428                    expected: vec![n_rows, block.ncols()],
1429                    actual: vec![block.nrows(), block.ncols()],
1430                    context: format!(
1431                        "FeatureUnion transformer `{name}` produced {} rows, expected {n_rows} \
1432                         (every sub-transformer output must be row-aligned for hstack)",
1433                        block.nrows()
1434                    ),
1435                });
1436            }
1437            total_width += block.ncols();
1438            blocks.push(block);
1439        }
1440
1441        // Allocate the concatenated output and copy each block into its
1442        // contiguous column range, left-to-right (bounds-safe: `col_offset` and
1443        // each block width are derived from the blocks just collected).
1444        let mut out = Array2::<F>::zeros((n_rows, total_width));
1445        let mut col_offset = 0usize;
1446        for block in &blocks {
1447            let width = block.ncols();
1448            for r in 0..n_rows {
1449                for c in 0..width {
1450                    out[[r, col_offset + c]] = block[[r, c]];
1451                }
1452            }
1453            col_offset += width;
1454        }
1455
1456        Ok(out)
1457    }
1458}
1459
1460// ---------------------------------------------------------------------------
1461// Tests
1462// ---------------------------------------------------------------------------
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467
1468    // -- Test fixtures -------------------------------------------------------
1469
1470    /// A trivial transformer that doubles all values.
1471    struct DoublingTransformer;
1472
1473    impl PipelineTransformer<f64> for DoublingTransformer {
1474        fn fit_pipeline(
1475            &self,
1476            _x: &Array2<f64>,
1477            _y: &Array1<f64>,
1478        ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
1479            Ok(Box::new(FittedDoublingTransformer))
1480        }
1481    }
1482
1483    struct FittedDoublingTransformer;
1484
1485    impl FittedPipelineTransformer<f64> for FittedDoublingTransformer {
1486        fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
1487            Ok(x.mapv(|v| v * 2.0))
1488        }
1489    }
1490
1491    /// A trivial estimator that sums each row.
1492    struct SumEstimator;
1493
1494    impl PipelineEstimator<f64> for SumEstimator {
1495        fn fit_pipeline(
1496            &self,
1497            _x: &Array2<f64>,
1498            _y: &Array1<f64>,
1499        ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
1500            Ok(Box::new(FittedSumEstimator))
1501        }
1502    }
1503
1504    struct FittedSumEstimator;
1505
1506    impl FittedPipelineEstimator<f64> for FittedSumEstimator {
1507        fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
1508            let sums: Vec<f64> = x.rows().into_iter().map(|row| row.sum()).collect();
1509            Ok(Array1::from_vec(sums))
1510        }
1511    }
1512
1513    // -- f32 test fixtures ---------------------------------------------------
1514
1515    /// A trivial f32 transformer that doubles all values.
1516    struct DoublingTransformerF32;
1517
1518    impl PipelineTransformer<f32> for DoublingTransformerF32 {
1519        fn fit_pipeline(
1520            &self,
1521            _x: &Array2<f32>,
1522            _y: &Array1<f32>,
1523        ) -> Result<Box<dyn FittedPipelineTransformer<f32>>, FerroError> {
1524            Ok(Box::new(FittedDoublingTransformerF32))
1525        }
1526    }
1527
1528    struct FittedDoublingTransformerF32;
1529
1530    impl FittedPipelineTransformer<f32> for FittedDoublingTransformerF32 {
1531        fn transform_pipeline(&self, x: &Array2<f32>) -> Result<Array2<f32>, FerroError> {
1532            Ok(x.mapv(|v| v * 2.0))
1533        }
1534    }
1535
1536    /// A trivial f32 estimator that sums each row.
1537    struct SumEstimatorF32;
1538
1539    impl PipelineEstimator<f32> for SumEstimatorF32 {
1540        fn fit_pipeline(
1541            &self,
1542            _x: &Array2<f32>,
1543            _y: &Array1<f32>,
1544        ) -> Result<Box<dyn FittedPipelineEstimator<f32>>, FerroError> {
1545            Ok(Box::new(FittedSumEstimatorF32))
1546        }
1547    }
1548
1549    struct FittedSumEstimatorF32;
1550
1551    impl FittedPipelineEstimator<f32> for FittedSumEstimatorF32 {
1552        fn predict_pipeline(&self, x: &Array2<f32>) -> Result<Array1<f32>, FerroError> {
1553            let sums: Vec<f32> = x.rows().into_iter().map(|row| row.sum()).collect();
1554            Ok(Array1::from_vec(sums))
1555        }
1556    }
1557
1558    // -- Tests ---------------------------------------------------------------
1559
1560    #[test]
1561    fn test_pipeline_fit_predict() {
1562        let pipeline = Pipeline::new()
1563            .transform_step("doubler", Box::new(DoublingTransformer))
1564            .estimator_step("sum", Box::new(SumEstimator));
1565
1566        let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1567        let y = Array1::from_vec(vec![0.0, 1.0]);
1568
1569        let fitted = pipeline.fit(&x, &y).unwrap();
1570        let preds = fitted.predict(&x).unwrap();
1571
1572        // After doubling: [[2,4,6],[8,10,12]], sums: [12, 30]
1573        assert_eq!(preds.len(), 2);
1574        assert!((preds[0] - 12.0).abs() < 1e-10);
1575        assert!((preds[1] - 30.0).abs() < 1e-10);
1576    }
1577
1578    #[test]
1579    fn test_pipeline_f32_fit_predict() {
1580        let pipeline = Pipeline::<f32>::new()
1581            .transform_step("doubler", Box::new(DoublingTransformerF32))
1582            .estimator_step("sum", Box::new(SumEstimatorF32));
1583
1584        let x = Array2::from_shape_vec((2, 3), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1585        let y = Array1::from_vec(vec![0.0f32, 1.0]);
1586
1587        let fitted = pipeline.fit(&x, &y).unwrap();
1588        let preds = fitted.predict(&x).unwrap();
1589
1590        assert_eq!(preds.len(), 2);
1591        assert!((preds[0] - 12.0).abs() < 1e-5);
1592        assert!((preds[1] - 30.0).abs() < 1e-5);
1593    }
1594
1595    #[test]
1596    fn test_pipeline_step_builder() {
1597        let pipeline = Pipeline::new()
1598            .step("doubler", as_transform_step(DoublingTransformer))
1599            .step("sum", as_estimator_step(SumEstimator));
1600
1601        let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1602        let y = Array1::from_vec(vec![0.0, 1.0]);
1603
1604        let fitted = pipeline.fit(&x, &y).unwrap();
1605        let preds = fitted.predict(&x).unwrap();
1606
1607        assert!((preds[0] - 12.0).abs() < 1e-10);
1608        assert!((preds[1] - 30.0).abs() < 1e-10);
1609    }
1610
1611    #[test]
1612    fn test_pipeline_rejects_inconsistent_x_y() {
1613        // sklearn's Pipeline.fit validates X/y consistency before fitting any
1614        // step (check_consistent_length, validation.py:1320): a mismatched
1615        // n_samples raises ValueError. Live oracle:
1616        //   from sklearn.pipeline import Pipeline
1617        //   from sklearn.preprocessing import StandardScaler
1618        //   from sklearn.naive_bayes import GaussianNB; import numpy as np
1619        //   p = Pipeline([("s", StandardScaler()), ("c", GaussianNB())])
1620        //   try: p.fit(np.zeros((3,2)), np.zeros(4)); print("OK")
1621        //   except ValueError: print("RAISE")          # -> RAISE
1622        let pipeline = Pipeline::new()
1623            .transform_step("doubler", Box::new(DoublingTransformer))
1624            .estimator_step("sum", Box::new(SumEstimator));
1625        let x = Array2::<f64>::zeros((3, 2));
1626        let y = Array1::from_vec(vec![0.0, 1.0]); // len 2 != 3 rows
1627        let result = pipeline.fit(&x, &y);
1628        assert!(matches!(result, Err(FerroError::ShapeMismatch { .. })));
1629    }
1630
1631    #[test]
1632    fn test_pipeline_accepts_consistent_x_y() -> Result<(), FerroError> {
1633        // The guard must not reject well-formed X/y (live oracle: same Pipeline
1634        // with matching shapes -> OK).
1635        let pipeline = Pipeline::new()
1636            .transform_step("doubler", Box::new(DoublingTransformer))
1637            .estimator_step("sum", Box::new(SumEstimator));
1638        let x =
1639            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).map_err(|e| {
1640                FerroError::InvalidParameter {
1641                    name: "x".into(),
1642                    reason: e.to_string(),
1643                }
1644            })?;
1645        let y = Array1::from_vec(vec![0.0, 1.0]);
1646        let fitted = pipeline.fit(&x, &y)?;
1647        assert_eq!(fitted.predict(&x)?.len(), 2);
1648        Ok(())
1649    }
1650
1651    #[test]
1652    fn test_pipeline_no_estimator_returns_error() {
1653        let pipeline = Pipeline::new().transform_step("doubler", Box::new(DoublingTransformer));
1654
1655        let x = Array2::<f64>::zeros((2, 3));
1656        let y = Array1::from_vec(vec![0.0, 1.0]);
1657
1658        let result = pipeline.fit(&x, &y);
1659        assert!(result.is_err());
1660    }
1661
1662    #[test]
1663    fn test_pipeline_estimator_only() {
1664        let pipeline = Pipeline::new().estimator_step("sum", Box::new(SumEstimator));
1665
1666        let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1667        let y = Array1::from_vec(vec![0.0, 1.0]);
1668
1669        let fitted = pipeline.fit(&x, &y).unwrap();
1670        let preds = fitted.predict(&x).unwrap();
1671
1672        // No transform, just sum: [6, 15]
1673        assert!((preds[0] - 6.0).abs() < 1e-10);
1674        assert!((preds[1] - 15.0).abs() < 1e-10);
1675    }
1676
1677    #[test]
1678    fn test_fitted_pipeline_step_names() {
1679        let pipeline = Pipeline::new()
1680            .transform_step("scaler", Box::new(DoublingTransformer))
1681            .transform_step("normalizer", Box::new(DoublingTransformer))
1682            .estimator_step("clf", Box::new(SumEstimator));
1683
1684        let x = Array2::<f64>::zeros((2, 3));
1685        let y = Array1::from_vec(vec![0.0, 1.0]);
1686
1687        let fitted = pipeline.fit(&x, &y).unwrap();
1688        let names = fitted.step_names();
1689        assert_eq!(names, vec!["scaler", "normalizer", "clf"]);
1690    }
1691
1692    #[test]
1693    fn test_multiple_transform_steps() {
1694        // Two doublers in sequence should quadruple values.
1695        let pipeline = Pipeline::new()
1696            .transform_step("double1", Box::new(DoublingTransformer))
1697            .transform_step("double2", Box::new(DoublingTransformer))
1698            .estimator_step("sum", Box::new(SumEstimator));
1699
1700        let x = Array2::from_shape_vec((1, 2), vec![1.0, 1.0]).unwrap();
1701        let y = Array1::from_vec(vec![0.0]);
1702
1703        let fitted = pipeline.fit(&x, &y).unwrap();
1704        let preds = fitted.predict(&x).unwrap();
1705
1706        // 1.0 * 2 * 2 = 4.0 per element, sum of 2 elements = 8.0
1707        assert!((preds[0] - 8.0).abs() < 1e-10);
1708    }
1709
1710    #[test]
1711    fn test_pipeline_default() {
1712        let pipeline = Pipeline::<f64>::default();
1713        let x = Array2::<f64>::zeros((2, 3));
1714        let y = Array1::from_vec(vec![0.0, 1.0]);
1715        // Should error because no estimator.
1716        assert!(pipeline.fit(&x, &y).is_err());
1717    }
1718
1719    #[test]
1720    fn test_pipeline_is_send_sync() {
1721        fn assert_send_sync<T: Send + Sync>() {}
1722        // Pipeline itself is Send+Sync because it only stores
1723        // Send+Sync trait objects.
1724        assert_send_sync::<Pipeline<f64>>();
1725        assert_send_sync::<Pipeline<f32>>();
1726        assert_send_sync::<FittedPipeline<f64>>();
1727        assert_send_sync::<FittedPipeline<f32>>();
1728    }
1729
1730    // -- REQ-3: fit_transform / transform / predict_proba / decision_function /
1731    //    score ---------------------------------------------------------------
1732
1733    /// An estimator that overrides the probability/decision/score delegations,
1734    /// proving the new default-Err trait methods can be overridden by a real
1735    /// final estimator (mirrors how `GaussianNB` does so in `gaussian.rs`).
1736    struct ProbaEstimator;
1737
1738    impl PipelineEstimator<f64> for ProbaEstimator {
1739        fn fit_pipeline(
1740            &self,
1741            _x: &Array2<f64>,
1742            _y: &Array1<f64>,
1743        ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
1744            Ok(Box::new(FittedProbaEstimator))
1745        }
1746    }
1747
1748    struct FittedProbaEstimator;
1749
1750    impl FittedPipelineEstimator<f64> for FittedProbaEstimator {
1751        fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
1752            // Predict 1.0 when the row sum is positive, else 0.0.
1753            Ok(Array1::from_iter(
1754                x.rows()
1755                    .into_iter()
1756                    .map(|r| if r.sum() > 0.0 { 1.0 } else { 0.0 }),
1757            ))
1758        }
1759
1760        fn predict_proba_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
1761            // A deterministic two-column "probability" (sigmoid of row sum).
1762            let mut out = Array2::<f64>::zeros((x.nrows(), 2));
1763            for (i, r) in x.rows().into_iter().enumerate() {
1764                let p1 = 1.0 / (1.0 + (-r.sum()).exp());
1765                out[[i, 0]] = 1.0 - p1;
1766                out[[i, 1]] = p1;
1767            }
1768            Ok(out)
1769        }
1770
1771        fn score_pipeline(&self, x: &Array2<f64>, y: &Array1<f64>) -> Result<f64, FerroError> {
1772            let preds = self.predict_pipeline(x)?;
1773            let n = y.len();
1774            if n == 0 {
1775                return Ok(0.0);
1776            }
1777            let correct = preds
1778                .iter()
1779                .zip(y.iter())
1780                .filter(|(p, t)| (**p - **t).abs() < 1e-12)
1781                .count();
1782            Ok(correct as f64 / n as f64)
1783        }
1784    }
1785
1786    #[test]
1787    fn test_pipeline_fit_transform_equals_transform() -> Result<(), FerroError> {
1788        // fit_transform must return exactly what FittedPipeline::transform
1789        // returns on the same input (fit-then-transform ≡ fused fit_transform).
1790        let pipeline = Pipeline::new()
1791            .transform_step("double1", Box::new(DoublingTransformer))
1792            .transform_step("double2", Box::new(DoublingTransformer))
1793            .estimator_step("sum", Box::new(SumEstimator));
1794
1795        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
1796        let y = Array1::from_vec(vec![0.0, 1.0]);
1797
1798        let (fitted, xt) = pipeline.fit_transform(&x, &y)?;
1799        // Two doublers quadruple the data.
1800        let expected = x.mapv(|v| v * 4.0);
1801        assert_eq!(xt, expected);
1802        // transform() on the fitted pipeline matches fit_transform's output.
1803        let xt2 = fitted.transform(&x)?;
1804        assert_eq!(xt2, expected);
1805        Ok(())
1806    }
1807
1808    #[test]
1809    fn test_pipeline_transform_applies_only_transformer_steps() -> Result<(), FerroError> {
1810        // FittedPipeline::transform returns the data the estimator would see —
1811        // i.e. only the transformer prefix is applied, not the estimator.
1812        let pipeline = Pipeline::new()
1813            .transform_step("doubler", Box::new(DoublingTransformer))
1814            .estimator_step("sum", Box::new(SumEstimator));
1815        let x = ndarray::array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1816        let y = Array1::from_vec(vec![0.0, 1.0]);
1817        let fitted = pipeline.fit(&x, &y)?;
1818        let xt = fitted.transform(&x)?;
1819        assert_eq!(xt, x.mapv(|v| v * 2.0));
1820        Ok(())
1821    }
1822
1823    #[test]
1824    fn test_pipeline_predict_proba_default_is_err() -> Result<(), FerroError> {
1825        // SumEstimator does not override predict_proba_pipeline → the default
1826        // Err (sklearn AttributeError analog) fires.
1827        let pipeline = Pipeline::new()
1828            .transform_step("doubler", Box::new(DoublingTransformer))
1829            .estimator_step("sum", Box::new(SumEstimator));
1830        let x = ndarray::array![[1.0, 1.0]];
1831        let y = Array1::from_vec(vec![0.0]);
1832        let fitted = pipeline.fit(&x, &y)?;
1833        assert!(matches!(
1834            fitted.predict_proba(&x),
1835            Err(FerroError::InvalidParameter { .. })
1836        ));
1837        assert!(matches!(
1838            fitted.decision_function(&x),
1839            Err(FerroError::InvalidParameter { .. })
1840        ));
1841        assert!(matches!(
1842            fitted.score(&x, &y),
1843            Err(FerroError::InvalidParameter { .. })
1844        ));
1845        Ok(())
1846    }
1847
1848    #[test]
1849    fn test_pipeline_predict_proba_and_score_override() -> Result<(), FerroError> {
1850        // ProbaEstimator overrides the delegations. The transformer doubles the
1851        // data; the proba estimator sees the doubled rows.
1852        let pipeline = Pipeline::new()
1853            .transform_step("doubler", Box::new(DoublingTransformer))
1854            .estimator_step("clf", Box::new(ProbaEstimator));
1855        let x = ndarray::array![[1.0], [-2.0]];
1856        let y = Array1::from_vec(vec![1.0, 0.0]);
1857        let fitted = pipeline.fit(&x, &y)?;
1858
1859        // Doubled rows: [2.0], [-4.0]. p1 = sigmoid(row sum).
1860        let proba = fitted.predict_proba(&x)?;
1861        assert_eq!(proba.dim(), (2, 2));
1862        for i in 0..2 {
1863            assert!((proba.row(i).sum() - 1.0).abs() < 1e-12);
1864        }
1865        let p1_row0 = 1.0 / (1.0 + (-2.0f64).exp());
1866        assert!((proba[[0, 1]] - p1_row0).abs() < 1e-12);
1867
1868        // Both rows predicted correctly → score 1.0.
1869        let s = fitted.score(&x, &y)?;
1870        assert!((s - 1.0).abs() < 1e-12);
1871        Ok(())
1872    }
1873
1874    // -- REQ-4a: named_steps / get_step / get_step_by_name / into_slice -------
1875
1876    fn is_transformer(r: &PipelineStepRef<'_, f64>) -> bool {
1877        matches!(r, PipelineStepRef::Transformer(_))
1878    }
1879    fn is_estimator(r: &PipelineStepRef<'_, f64>) -> bool {
1880        matches!(r, PipelineStepRef::Estimator(_))
1881    }
1882
1883    #[test]
1884    fn test_pipeline_named_steps_match_sklearn() {
1885        // sklearn: Pipeline([('a',StandardScaler()),('b',MinMaxScaler()),
1886        //                    ('c',GaussianNB())]).named_steps keys order
1887        //   == ['a', 'b', 'c']  (live oracle, sklearn 1.5.2;
1888        //   `named_steps = Bunch(**dict(self.steps))`, pipeline.py:325).
1889        // Every step is reachable by its construction name, in order.
1890        let pipeline = Pipeline::new()
1891            .transform_step("a", Box::new(DoublingTransformer))
1892            .transform_step("b", Box::new(DoublingTransformer))
1893            .estimator_step("c", Box::new(SumEstimator));
1894
1895        let named = pipeline.named_steps();
1896        let names: Vec<&str> = named.iter().map(|(n, _)| *n).collect();
1897        assert_eq!(names, vec!["a", "b", "c"]);
1898        // The two transformer steps are transformers; the final is the estimator.
1899        assert!(is_transformer(&named[0].1));
1900        assert!(is_transformer(&named[1].1));
1901        assert!(is_estimator(&named[2].1));
1902        // step_names() agrees with named_steps() key order.
1903        assert_eq!(pipeline.step_names(), names);
1904        // len() counts every step (3), matching sklearn len(pipe)==3.
1905        assert_eq!(pipeline.len(), 3);
1906        assert!(!pipeline.is_empty());
1907    }
1908
1909    #[test]
1910    fn test_pipeline_get_step_integer() {
1911        // sklearn: p[0] -> first step object, p[2] -> last (estimator);
1912        //   p[10] -> IndexError (live oracle). ferrolearn returns None OOB.
1913        let pipeline = Pipeline::new()
1914            .transform_step("a", Box::new(DoublingTransformer))
1915            .transform_step("b", Box::new(DoublingTransformer))
1916            .estimator_step("c", Box::new(SumEstimator));
1917
1918        assert!(matches!(
1919            pipeline.get_step(0),
1920            Some(PipelineStepRef::Transformer(_))
1921        ));
1922        assert!(matches!(
1923            pipeline.get_step(1),
1924            Some(PipelineStepRef::Transformer(_))
1925        ));
1926        assert!(matches!(
1927            pipeline.get_step(2),
1928            Some(PipelineStepRef::Estimator(_))
1929        ));
1930        // Out of range -> None (sklearn raises IndexError).
1931        assert!(pipeline.get_step(3).is_none());
1932        assert!(pipeline.get_step(10).is_none());
1933    }
1934
1935    #[test]
1936    fn test_pipeline_get_step_by_name() {
1937        // sklearn: p['b'] -> the 'b' step; p['nope'] -> KeyError (live oracle).
1938        let pipeline = Pipeline::new()
1939            .transform_step("a", Box::new(DoublingTransformer))
1940            .transform_step("b", Box::new(DoublingTransformer))
1941            .estimator_step("c", Box::new(SumEstimator));
1942
1943        assert!(matches!(
1944            pipeline.get_step_by_name("b"),
1945            Some(PipelineStepRef::Transformer(_))
1946        ));
1947        assert!(matches!(
1948            pipeline.get_step_by_name("c"),
1949            Some(PipelineStepRef::Estimator(_))
1950        ));
1951        assert!(matches!(
1952            pipeline.named_step("a"),
1953            Some(PipelineStepRef::Transformer(_))
1954        ));
1955        // Unknown name -> None (sklearn raises KeyError).
1956        assert!(pipeline.get_step_by_name("nope").is_none());
1957        assert!(pipeline.named_step("nope").is_none());
1958    }
1959
1960    #[test]
1961    fn test_pipeline_into_slice() -> Result<(), FerroError> {
1962        // sklearn: p[0:2].steps names == ['a','b'] (a sub-Pipeline of the
1963        //   contiguous range; pipeline.py:310). p[:1] == ['a']. p[:] == all.
1964        //   p[1:1] == [] (empty). (live oracle, sklearn 1.5.2.)
1965        let build = || {
1966            Pipeline::new()
1967                .transform_step("a", Box::new(DoublingTransformer))
1968                .transform_step("b", Box::new(DoublingTransformer))
1969                .estimator_step("c", Box::new(SumEstimator))
1970        };
1971
1972        // [0, 2) -> first two transformer steps, no estimator.
1973        let sub = build().into_slice(0, 2);
1974        assert_eq!(sub.step_names(), vec!["a", "b"]);
1975        assert_eq!(sub.len(), 2);
1976
1977        // [0, 1) -> just the first step.
1978        let sub = build().into_slice(0, 1);
1979        assert_eq!(sub.step_names(), vec!["a"]);
1980
1981        // [0, 3) -> the whole pipeline (full range), estimator preserved.
1982        let sub = build().into_slice(0, 3);
1983        assert_eq!(sub.step_names(), vec!["a", "b", "c"]);
1984
1985        // [2, 3) -> just the estimator step.
1986        let sub = build().into_slice(2, 3);
1987        assert_eq!(sub.step_names(), vec!["c"]);
1988
1989        // Empty range -> empty pipeline.
1990        let sub = build().into_slice(1, 1);
1991        assert!(sub.step_names().is_empty());
1992        assert!(sub.is_empty());
1993
1994        Ok(())
1995    }
1996
1997    #[test]
1998    fn test_pipeline_into_slice_clamps_like_python() {
1999        // sklearn `Pipeline.__getitem__` slices `self.steps[ind]` (Python list
2000        // slice, `pipeline.py:310`): out-of-range bounds CLAMP, never raise
2001        // (#2235). Live oracle (sklearn 1.5.2, 2-step pipeline):
2002        //   p[0:5].steps -> ['a','c'] (clamp); p[2:1] -> []; p[5:100] -> [].
2003        let build = || {
2004            Pipeline::new()
2005                .transform_step("a", Box::new(DoublingTransformer))
2006                .estimator_step("c", Box::new(SumEstimator))
2007        };
2008        // end past len (2) -> clamp to all.
2009        assert_eq!(build().into_slice(0, 5).step_names(), vec!["a", "c"]);
2010        // start > end -> empty.
2011        assert!(build().into_slice(2, 1).is_empty());
2012        // start past len -> empty.
2013        assert!(build().into_slice(5, 100).is_empty());
2014    }
2015
2016    #[test]
2017    fn test_pipeline_into_slice_transformer_only_still_fits_estimatorless() -> Result<(), FerroError>
2018    {
2019        // A slice dropping the estimator yields an estimator-less pipeline that
2020        // (like sklearn's transformer-only sub-pipeline) is valid to build but
2021        // errors at fit (matches REQ-2's no-estimator rejection).
2022        let pipeline = Pipeline::new()
2023            .transform_step("a", Box::new(DoublingTransformer))
2024            .estimator_step("c", Box::new(SumEstimator));
2025        let sub = pipeline.into_slice(0, 1);
2026        let x = Array2::<f64>::zeros((2, 2));
2027        let y = Array1::from_vec(vec![0.0, 1.0]);
2028        assert!(matches!(
2029            sub.fit(&x, &y),
2030            Err(FerroError::InvalidParameter { .. })
2031        ));
2032        Ok(())
2033    }
2034
2035    #[test]
2036    fn test_fitted_pipeline_named_steps_and_get_step() -> Result<(), FerroError> {
2037        // The accessors work on the FITTED pipeline too. Names match
2038        // construction order (sklearn named_steps on a fitted Pipeline).
2039        let pipeline = Pipeline::new()
2040            .transform_step("scaler", Box::new(DoublingTransformer))
2041            .transform_step("norm", Box::new(DoublingTransformer))
2042            .estimator_step("clf", Box::new(SumEstimator));
2043        let x = Array2::<f64>::zeros((2, 3));
2044        let y = Array1::from_vec(vec![0.0, 1.0]);
2045        let fitted = pipeline.fit(&x, &y)?;
2046
2047        let names: Vec<&str> = fitted.named_steps().iter().map(|(n, _)| *n).collect();
2048        assert_eq!(names, vec!["scaler", "norm", "clf"]);
2049        assert_eq!(fitted.len(), 3);
2050        assert!(!fitted.is_empty());
2051
2052        // get_step by integer.
2053        assert!(matches!(
2054            fitted.get_step(0),
2055            Some(FittedPipelineStepRef::Transformer(_))
2056        ));
2057        assert!(matches!(
2058            fitted.get_step(2),
2059            Some(FittedPipelineStepRef::Estimator(_))
2060        ));
2061        assert!(fitted.get_step(3).is_none());
2062
2063        // get_step_by_name / named_step.
2064        assert!(matches!(
2065            fitted.get_step_by_name("norm"),
2066            Some(FittedPipelineStepRef::Transformer(_))
2067        ));
2068        assert!(matches!(
2069            fitted.named_step("clf"),
2070            Some(FittedPipelineStepRef::Estimator(_))
2071        ));
2072        assert!(fitted.named_step("nope").is_none());
2073        Ok(())
2074    }
2075
2076    // -- REQ-5a: passthrough steps -------------------------------------------
2077
2078    #[test]
2079    fn test_passthrough_step_is_identity() -> Result<(), FerroError> {
2080        // Live oracle (sklearn 1.5.2):
2081        //   from sklearn.pipeline import Pipeline; import numpy as np
2082        //   X = np.array([[1.,2.],[3.,4.],[5.,6.]])
2083        //   p = Pipeline([('p','passthrough')]).fit(X)
2084        //   np.array_equal(p.transform(X), X)   -> True
2085        // A pipeline whose only transformer is a passthrough step leaves X
2086        // unchanged. ferrolearn needs a final estimator slot to fit, so we add a
2087        // SumEstimator after; transform() (the transformer prefix) must equal X.
2088        let pipeline = Pipeline::new()
2089            .passthrough_step("p")
2090            .estimator_step("sum", Box::new(SumEstimator));
2091        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2092        let y = Array1::from_vec(vec![0.0, 1.0, 2.0]);
2093        let fitted = pipeline.fit(&x, &y)?;
2094        // transform() applies only the (passthrough) transformer prefix -> X.
2095        assert_eq!(fitted.transform(&x)?, x);
2096        Ok(())
2097    }
2098
2099    #[test]
2100    fn test_passthrough_before_transformer_is_noop() -> Result<(), FerroError> {
2101        // Live oracle (sklearn 1.5.2):
2102        //   Pipeline([('pass','passthrough'),('ss',StandardScaler())]).fit(X)
2103        //     .transform(X)
2104        //   == Pipeline([('ss',StandardScaler())]).fit(X).transform(X)   -> True
2105        // A passthrough BEFORE a real transformer is a no-op: the result equals
2106        // the transformer alone. ferrolearn analog: a passthrough before a
2107        // DoublingTransformer == the doubler alone.
2108        let with_pass = Pipeline::new()
2109            .passthrough_step("pass")
2110            .transform_step("dbl", Box::new(DoublingTransformer))
2111            .estimator_step("sum", Box::new(SumEstimator));
2112        let without_pass = Pipeline::new()
2113            .transform_step("dbl", Box::new(DoublingTransformer))
2114            .estimator_step("sum", Box::new(SumEstimator));
2115        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
2116        let y = Array1::from_vec(vec![0.0, 1.0]);
2117
2118        let a = with_pass.fit(&x, &y)?.transform(&x)?;
2119        let b = without_pass.fit(&x, &y)?.transform(&x)?;
2120        assert_eq!(a, b);
2121        // And it equals the doubler applied to X.
2122        assert_eq!(a, x.mapv(|v| v * 2.0));
2123        Ok(())
2124    }
2125
2126    #[test]
2127    fn test_passthrough_after_transformer_is_noop() -> Result<(), FerroError> {
2128        // Live oracle (sklearn 1.5.2):
2129        //   Pipeline([('ss',StandardScaler()),('pass','passthrough')]).transform(X)
2130        //   == Pipeline([('ss',StandardScaler())]).transform(X)   -> True
2131        // A passthrough AFTER a real transformer is a no-op. ferrolearn analog:
2132        // doubler then passthrough == doubler alone.
2133        let with_pass = Pipeline::new()
2134            .transform_step("dbl", Box::new(DoublingTransformer))
2135            .passthrough_step("pass")
2136            .estimator_step("sum", Box::new(SumEstimator));
2137        let without_pass = Pipeline::new()
2138            .transform_step("dbl", Box::new(DoublingTransformer))
2139            .estimator_step("sum", Box::new(SumEstimator));
2140        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
2141        let y = Array1::from_vec(vec![0.0, 1.0]);
2142
2143        let a = with_pass.fit(&x, &y)?.transform(&x)?;
2144        let b = without_pass.fit(&x, &y)?.transform(&x)?;
2145        assert_eq!(a, b);
2146        assert_eq!(a, x.mapv(|v| v * 2.0));
2147        Ok(())
2148    }
2149
2150    #[test]
2151    fn test_passthrough_step_appears_in_step_names() -> Result<(), FerroError> {
2152        // Live oracle (sklearn 1.5.2):
2153        //   p = Pipeline([('p','passthrough'),('ss',StandardScaler())]).fit(X)
2154        //   list(p.named_steps.keys())  -> ['p', 'ss']
2155        //   p['p']                      -> 'passthrough'   (still visible)
2156        // A passthrough step is a real, named step: it shows up in
2157        // step_names()/named_steps() in order, exactly like sklearn.
2158        let pipeline = Pipeline::new()
2159            .passthrough_step("p")
2160            .transform_step("dbl", Box::new(DoublingTransformer))
2161            .estimator_step("clf", Box::new(SumEstimator));
2162
2163        assert_eq!(pipeline.step_names(), vec!["p", "dbl", "clf"]);
2164        let named: Vec<&str> = pipeline.named_steps().iter().map(|(n, _)| *n).collect();
2165        assert_eq!(named, vec!["p", "dbl", "clf"]);
2166        // The passthrough step is a transformer-kind step (reachable by name).
2167        assert!(matches!(
2168            pipeline.named_step("p"),
2169            Some(PipelineStepRef::Transformer(_))
2170        ));
2171
2172        // And it survives onto the fitted pipeline's introspection.
2173        let x = Array2::<f64>::zeros((2, 2));
2174        let y = Array1::from_vec(vec![0.0, 1.0]);
2175        let fitted = pipeline.fit(&x, &y)?;
2176        assert_eq!(fitted.step_names(), vec!["p", "dbl", "clf"]);
2177        assert!(matches!(
2178            fitted.named_step("p"),
2179            Some(FittedPipelineStepRef::Transformer(_))
2180        ));
2181        Ok(())
2182    }
2183
2184    #[test]
2185    fn test_passthrough_transformer_standalone_identity() -> Result<(), FerroError> {
2186        // A standalone PassthroughTransformer: fit_pipeline + transform_pipeline
2187        // is the identity (the building block the no-op step is made of). This is
2188        // the pointwise restatement of sklearn's 'passthrough' == identity
2189        // (Pipeline([('p','passthrough')]).transform(X) == X, live 1.5.2).
2190        let p = PassthroughTransformer::<f64>::new();
2191        let x = ndarray::array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
2192        let y = Array1::from_vec(vec![0.0, 1.0]);
2193        let fitted = p.fit_pipeline(&x, &y)?;
2194        assert_eq!(fitted.transform_pipeline(&x)?, x);
2195        // Default constructs the same no-op.
2196        let fitted2 = PassthroughTransformer::<f64>::default().fit_pipeline(&x, &y)?;
2197        assert_eq!(fitted2.transform_pipeline(&x)?, x);
2198        // The fitted half also has a public constructor/Default.
2199        assert_eq!(
2200            FittedPassthroughTransformer::<f64>::new().transform_pipeline(&x)?,
2201            x
2202        );
2203        Ok(())
2204    }
2205
2206    #[test]
2207    fn test_passthrough_transformer_f32() -> Result<(), FerroError> {
2208        // f32 generic support: the identity no-op for f32 data.
2209        let pipeline = Pipeline::<f32>::new()
2210            .passthrough_step("p")
2211            .transform_step("dbl", Box::new(DoublingTransformerF32))
2212            .estimator_step("sum", Box::new(SumEstimatorF32));
2213        let x = ndarray::array![[1.0f32, 2.0], [3.0, 4.0]];
2214        let y = Array1::from_vec(vec![0.0f32, 1.0]);
2215        let fitted = pipeline.fit(&x, &y)?;
2216        // passthrough then doubler == doubler alone.
2217        assert_eq!(fitted.transform(&x)?, x.mapv(|v| v * 2.0));
2218        Ok(())
2219    }
2220
2221    #[test]
2222    fn test_passthrough_transformer_is_send_sync() {
2223        fn assert_send_sync<T: Send + Sync>() {}
2224        assert_send_sync::<PassthroughTransformer<f64>>();
2225        assert_send_sync::<PassthroughTransformer<f32>>();
2226        assert_send_sync::<FittedPassthroughTransformer<f64>>();
2227        assert_send_sync::<FittedPassthroughTransformer<f32>>();
2228    }
2229
2230    // -- REQ-8: FeatureUnion -------------------------------------------------
2231
2232    /// A transformer that returns its input columns unchanged (width-preserving,
2233    /// the OneToOneFeatureMixin shape — like sklearn's StandardScaler).
2234    struct IdentityTransformer;
2235
2236    impl PipelineTransformer<f64> for IdentityTransformer {
2237        fn fit_pipeline(
2238            &self,
2239            _x: &Array2<f64>,
2240            _y: &Array1<f64>,
2241        ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
2242            Ok(Box::new(FittedIdentityTransformer))
2243        }
2244    }
2245
2246    struct FittedIdentityTransformer;
2247
2248    impl FittedPipelineTransformer<f64> for FittedIdentityTransformer {
2249        fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
2250            Ok(x.clone())
2251        }
2252    }
2253
2254    /// A transformer that emits a single column: the row sum (width 1, regardless
2255    /// of input width). Used to exercise mixed-width hstack blocks.
2256    struct RowSumTransformer;
2257
2258    impl PipelineTransformer<f64> for RowSumTransformer {
2259        fn fit_pipeline(
2260            &self,
2261            _x: &Array2<f64>,
2262            _y: &Array1<f64>,
2263        ) -> Result<Box<dyn FittedPipelineTransformer<f64>>, FerroError> {
2264            Ok(Box::new(FittedRowSumTransformer))
2265        }
2266    }
2267
2268    struct FittedRowSumTransformer;
2269
2270    impl FittedPipelineTransformer<f64> for FittedRowSumTransformer {
2271        fn transform_pipeline(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
2272            let sums: Vec<f64> = x.rows().into_iter().map(|r| r.sum()).collect();
2273            Array2::from_shape_vec((x.nrows(), 1), sums).map_err(|e| FerroError::InvalidParameter {
2274                name: "x".into(),
2275                reason: e.to_string(),
2276            })
2277        }
2278    }
2279
2280    #[test]
2281    fn test_feature_union_hstack_layout() -> Result<(), FerroError> {
2282        // sklearn (live, 1.5.2):
2283        //   from sklearn.pipeline import FeatureUnion
2284        //   from sklearn.preprocessing import StandardScaler, MinMaxScaler
2285        //   import numpy as np
2286        //   X = np.array([[1.,2.],[3.,4.],[5.,6.]])
2287        //   fu = FeatureUnion([('ss',StandardScaler()),('mm',MinMaxScaler())]).fit(X)
2288        //   fu.transform(X).shape        -> (3, 4)
2289        //   # columns = [ss_col0, ss_col1, mm_col0, mm_col1]  (each transformer's
2290        //   #   full output, concatenated left-to-right in transformer_list order)
2291        // The hstack STRUCTURE is what's asserted here: two width-2 identity
2292        // transformers → a width-4 output whose column blocks are each
2293        // transformer's full output (here, the unchanged input twice). The block
2294        // layout (transformer 0's cols, then transformer 1's cols) IS sklearn's
2295        // _hstack ordering (pipeline.py:1812 np.hstack(Xs)).
2296        let union = FeatureUnion::<f64>::new()
2297            .with_transformer("a", Box::new(IdentityTransformer))
2298            .with_transformer("b", Box::new(IdentityTransformer));
2299        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2300
2301        let fitted = union.fit(&x, &())?;
2302        let out = fitted.transform(&x)?;
2303
2304        // Width = sum of widths = 2 + 2 = 4; rows preserved.
2305        assert_eq!(out.dim(), (3, 4));
2306        // Block 0 (cols 0..2) = transformer "a"'s output (== x).
2307        assert_eq!(out.slice(ndarray::s![.., 0..2]).to_owned(), x);
2308        // Block 1 (cols 2..4) = transformer "b"'s output (== x).
2309        assert_eq!(out.slice(ndarray::s![.., 2..4]).to_owned(), x);
2310        Ok(())
2311    }
2312
2313    #[test]
2314    fn test_feature_union_get_feature_names_out() -> Result<(), FerroError> {
2315        // sklearn (live, 1.5.2): the SAME union as above ->
2316        //   list(fu.get_feature_names_out())
2317        //     == ['ss__x0','ss__x1','mm__x0','mm__x1']
2318        // i.e. each transformer's positional output names ('x0','x1' — the
2319        // OneToOneFeatureMixin default for StandardScaler/MinMaxScaler) prefixed
2320        // by '{name}__' (verbose_feature_names_out=True default, pipeline.py:1608).
2321        // ferrolearn's identity transformers are the width-preserving analog, so
2322        // the NAMING semantics (prefix + positional x{j}) match exactly.
2323        let union = FeatureUnion::<f64>::new()
2324            .with_transformer("ss", Box::new(IdentityTransformer))
2325            .with_transformer("mm", Box::new(IdentityTransformer));
2326        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2327
2328        let fitted = union.fit(&x, &())?;
2329        assert_eq!(
2330            fitted.get_feature_names_out(),
2331            vec!["ss__x0", "ss__x1", "mm__x0", "mm__x1"]
2332        );
2333        // transformer_names() preserves union order; n_transformers/n_features_out.
2334        assert_eq!(fitted.transformer_names(), vec!["ss", "mm"]);
2335        assert_eq!(fitted.n_transformers(), 2);
2336        assert_eq!(fitted.n_features_out(), 4);
2337        Ok(())
2338    }
2339
2340    #[test]
2341    fn test_feature_union_single_transformer_width() -> Result<(), FerroError> {
2342        // sklearn (live, 1.5.2):
2343        //   FeatureUnion([('ss',StandardScaler())]).fit(X).transform(X).shape
2344        //     -> (3, 2)   (single block == that transformer's width)
2345        //   get_feature_names_out() -> ['ss__x0','ss__x1']
2346        let union =
2347            FeatureUnion::<f64>::new().with_transformer("ss", Box::new(IdentityTransformer));
2348        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
2349
2350        let fitted = union.fit(&x, &())?;
2351        let out = fitted.transform(&x)?;
2352        assert_eq!(out.dim(), (3, 2));
2353        assert_eq!(out, x);
2354        assert_eq!(fitted.get_feature_names_out(), vec!["ss__x0", "ss__x1"]);
2355        Ok(())
2356    }
2357
2358    #[test]
2359    fn test_feature_union_mixed_widths() -> Result<(), FerroError> {
2360        // sklearn (live, 1.5.2) — a union whose transformers emit DIFFERENT
2361        // widths concatenates their blocks correctly. Oracle (StandardScaler
2362        // keeps 3 cols, PCA(1) emits 1):
2363        //   X = np.array([[1.,2.,3.],[3.,4.,5.],[5.,6.,7.]])
2364        //   fu = FeatureUnion([('ss',StandardScaler()),('pca',PCA(1))]).fit(X)
2365        //   fu.transform(X).shape -> (3, 4)   (3 + 1)
2366        //   list(fu.get_feature_names_out())
2367        //     -> ['ss__x0','ss__x1','ss__x2','pca__pca0']
2368        // ferrolearn analog: a width-3 identity + a width-1 row-sum transformer.
2369        // The STRUCTURE (block 0 width 3, block 1 width 1; total 4) is sklearn's.
2370        // (Names: ferrolearn uses the positional x{j} suffix for both blocks —
2371        // the documented OneToOneFeatureMixin default, since the trait objects
2372        // expose no per-output names.)
2373        let union = FeatureUnion::<f64>::new()
2374            .with_transformer("ident", Box::new(IdentityTransformer))
2375            .with_transformer("rowsum", Box::new(RowSumTransformer));
2376        let x = ndarray::array![[1.0, 2.0, 3.0], [3.0, 4.0, 5.0], [5.0, 6.0, 7.0]];
2377
2378        let fitted = union.fit(&x, &())?;
2379        let out = fitted.transform(&x)?;
2380        // 3 (identity) + 1 (row sum) = 4 columns.
2381        assert_eq!(out.dim(), (3, 4));
2382        // Block 0 == x (identity).
2383        assert_eq!(out.slice(ndarray::s![.., 0..3]).to_owned(), x);
2384        // Block 1 == row sums.
2385        let expected_sums = ndarray::array![[6.0], [12.0], [18.0]];
2386        assert_eq!(out.slice(ndarray::s![.., 3..4]).to_owned(), expected_sums);
2387        // Feature names reflect the per-block widths.
2388        assert_eq!(
2389            fitted.get_feature_names_out(),
2390            vec!["ident__x0", "ident__x1", "ident__x2", "rowsum__x0"]
2391        );
2392        Ok(())
2393    }
2394
2395    #[test]
2396    fn test_feature_union_empty() -> Result<(), FerroError> {
2397        // An empty union fits OK and transforms to a (n_samples, 0) matrix — the
2398        // ferrolearn analog of sklearn's empty-hstack branch
2399        //   `if not Xs: return np.zeros((X.shape[0], 0))` (pipeline.py:1808).
2400        // (sklearn's PUBLIC FeatureUnion([]).fit raises at _validate_transformers'
2401        // `zip(*[])`, a Python-tuple-unpack artifact, not a numerical contract —
2402        // R-DEV-4: ferrolearn has no such unpack, and the empty-hstack shape is
2403        // the documented (n, 0) result.)
2404        let union = FeatureUnion::<f64>::new();
2405        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0]];
2406        let fitted = union.fit(&x, &())?;
2407        let out = fitted.transform(&x)?;
2408        assert_eq!(out.dim(), (2, 0));
2409        assert!(fitted.get_feature_names_out().is_empty());
2410        assert_eq!(fitted.n_features_out(), 0);
2411        Ok(())
2412    }
2413
2414    #[test]
2415    fn test_feature_union_row_count_consistency() -> Result<(), FerroError> {
2416        // Every sub-output has n_rows == X.nrows(); the hstacked result preserves
2417        // the row count (live oracle: FeatureUnion outputs have X.shape[0] rows).
2418        let union = FeatureUnion::<f64>::new()
2419            .with_transformer("a", Box::new(IdentityTransformer))
2420            .with_transformer("b", Box::new(RowSumTransformer));
2421        let x = ndarray::array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
2422        let fitted = union.fit(&x, &())?;
2423        let out = fitted.transform(&x)?;
2424        assert_eq!(out.nrows(), x.nrows());
2425        Ok(())
2426    }
2427
2428    #[test]
2429    fn test_feature_union_f32() -> Result<(), FerroError> {
2430        // f32 generic support: same hstack layout for f32 data.
2431        let union = FeatureUnion::<f32>::new()
2432            .with_transformer("a", Box::new(IdentityTransformerF32))
2433            .with_transformer("b", Box::new(IdentityTransformerF32));
2434        let x = ndarray::array![[1.0f32, 2.0], [3.0, 4.0]];
2435        let fitted = union.fit(&x, &())?;
2436        let out = fitted.transform(&x)?;
2437        assert_eq!(out.dim(), (2, 4));
2438        assert_eq!(out.slice(ndarray::s![.., 0..2]).to_owned(), x);
2439        assert_eq!(out.slice(ndarray::s![.., 2..4]).to_owned(), x);
2440        Ok(())
2441    }
2442
2443    /// f32 identity transformer (width-preserving) for the f32 union test.
2444    struct IdentityTransformerF32;
2445
2446    impl PipelineTransformer<f32> for IdentityTransformerF32 {
2447        fn fit_pipeline(
2448            &self,
2449            _x: &Array2<f32>,
2450            _y: &Array1<f32>,
2451        ) -> Result<Box<dyn FittedPipelineTransformer<f32>>, FerroError> {
2452            Ok(Box::new(FittedIdentityTransformerF32))
2453        }
2454    }
2455
2456    struct FittedIdentityTransformerF32;
2457
2458    impl FittedPipelineTransformer<f32> for FittedIdentityTransformerF32 {
2459        fn transform_pipeline(&self, x: &Array2<f32>) -> Result<Array2<f32>, FerroError> {
2460            Ok(x.clone())
2461        }
2462    }
2463
2464    #[test]
2465    fn test_feature_union_is_send_sync() {
2466        fn assert_send_sync<T: Send + Sync>() {}
2467        assert_send_sync::<FeatureUnion<f64>>();
2468        assert_send_sync::<FeatureUnion<f32>>();
2469        assert_send_sync::<FittedFeatureUnion<f64>>();
2470        assert_send_sync::<FittedFeatureUnion<f32>>();
2471    }
2472}