Skip to main content

ferrolearn_tree/
decision_tree.rs

1//! CART decision tree classifiers and regressors.
2//!
3//! This module provides [`DecisionTreeClassifier`] and [`DecisionTreeRegressor`],
4//! implementing the Classification and Regression Trees (CART) algorithm with
5//! configurable splitting criteria, depth limits, and minimum sample constraints.
6//!
7//! # Examples
8//!
9//! ```
10//! use ferrolearn_tree::DecisionTreeClassifier;
11//! use ferrolearn_core::{Fit, Predict};
12//! use ndarray::{array, Array1, Array2};
13//!
14//! let x = Array2::from_shape_vec((6, 2), vec![
15//!     1.0, 2.0,  2.0, 3.0,  3.0, 3.0,
16//!     5.0, 6.0,  6.0, 7.0,  7.0, 8.0,
17//! ]).unwrap();
18//! let y = array![0, 0, 0, 1, 1, 1];
19//!
20//! let model = DecisionTreeClassifier::<f64>::new();
21//! let fitted = model.fit(&x, &y).unwrap();
22//! let preds = fitted.predict(&x).unwrap();
23//! ```
24//!
25//! ## REQ status
26//!
27//! Binary (R-DEFER-2): SHIPPED = impl + non-test consumer + tests + green oracle
28//! verification; NOT-STARTED = open blocker `#`. `DecisionTreeClassifier`/
29//! `DecisionTreeRegressor` are boundary estimator types re-exported at the crate
30//! root (`pub use decision_tree::{…}` in `lib.rs`) and registered as PyO3
31//! `RsDecisionTreeClassifier`; under S5/R-DEFER-1 those are the non-test consumer
32//! surface. Pins in `tests/divergence_decision_tree.rs`. See
33//! `.design/tree/decision_tree.md` for the full evidence + sklearn `file:line`.
34//!
35//! | REQ | Status | Evidence |
36//! |---|---|---|
37//! | REQ-1 (criteria gini/entropy/log_loss + mse/friedman_mse/absolute_error/poisson) | SHIPPED | `ClassificationCriterion`/`RegressionCriterion` + `fn regression_node_impurity`/`fn regression_leaf_value` (median for absolute_error); pinned by `req1_clf_log_loss_is_entropy_alias_oracle`, `req1_reg_friedman_mse_oracle` (+ `_differs_from_squared_error`), `req1_reg_absolute_error_median_leaves_oracle`, `req1_reg_poisson_oracle_and_negative_y_errors` (`_criterion.pyx`). |
38//! | REQ-2 (CART best-split + FEATURE_THRESHOLD band) | SHIPPED | `fn find_best_classification_split`/`fn find_best_regression_split` + `fn feature_threshold` (1e-7 constant band, `_splitter.pyx:33,405`); midpoint `(x[i]+x[i+1])/2`; pinned by `clf_tree_structure_oracle` (node_count 7, root `(1,5.5)`), `divergence_clf_feature_threshold_band`. Exact-improvement-tie root-feature choice is the documented `random_state` RNG boundary (`clf_tiebreak_predict_invariant_rng_boundary`). |
39//! | REQ-3 (stopping/pruning params) | SHIPPED | `max_depth`/`min_samples_split`/`min_samples_leaf` + `min_impurity_decrease` (`ImpurityGate`, `_tree.pyx:284`) + `min_weight_fraction_leaf` (`fn effective_min_samples_leaf`) + `ccp_alpha` (`fn prune_ccp`, Breiman weakest-link, `_tree.pyx:1617`) + `max_leaf_nodes` (best-first `fn build_*_best_first`, `_tree.pyx:407`). Pinned by `req3_clf_min_impurity_decrease_oracle`, `req3_clf_min_weight_fraction_leaf_oracle`, `req3_clf_ccp_alpha_oracle`/`req3_reg_ccp_alpha_oracle`, `req3_clf_max_leaf_nodes_oracle`/`req3_reg_max_leaf_nodes_oracle`. |
40//! | REQ-4 (max_features resolution + subsampling) | NOT-STARTED | open prereq blocker #665. `DecisionTreeClassifier`/`Regressor` expose no `max_features` param / `max_features_` attr; the `{sqrt,log2,float}` resolution is RNG-subsampling (documented boundary). |
41//! | REQ-5 (fitted attributes) | SHIPPED | `fn feature_importances` (`HasFeatureImportances`, consumed by `random_forest.rs` + PyO3), `fn classes`/`n_classes` (`HasClasses`), `fn nodes`, `fn n_features`; `feature_importances_` pinned by `clf_feature_importances_oracle` (`[0.18462,0.81538]`). |
42//! | REQ-6 (predict / predict_proba / multiclass) | SHIPPED | `fn predict`/`fn predict_proba`/`fn predict_log_proba` (consumed by `RsDecisionTreeClassifier` + the pipeline adapter); pinned by `clf_predict_and_proba_oracle` + the per-criterion/param predict pins. Multi-output (2-D y) NOT-STARTED (#668). |
43//! | REQ-7 (class_weight + random_state) | SHIPPED (class_weight) | `pub enum ClassWeight<F>{None,Balanced,Explicit}` + `with_class_weight` + `fn compute_class_weight` on `DecisionTreeClassifier` (regressor has none); weighted impurity/leaf/gates via `fn weighted_compute_impurity`/`fn weighted_classification_node_value` (forest/extra-tree path unchanged). Pinned by `req7_clf_class_weight_oracle` (None/Explicit/Balanced split+predict+proba) + `test_compute_class_weight_balanced`. `random_state`/`splitter='random'` determinism = NOT-STARTED RNG boundary #670. |
44//! | REQ-8 (ferray substrate) | NOT-STARTED | open prereq blocker #671. Imports `ndarray`, not `ferray-core` (R-SUBSTRATE). |
45//! | REQ-9 (native missing-value / NaN support) | SHIPPED | DecisionTree ACCEPTS NaN in `X` (`force_all_finite=False`, `_classes.py:248-250`); `fn fit` no longer rejects/aborts. The best-split search (`fn find_best_classification_split`/`fn find_best_regression_split`) sorts NaN last (`fn sort_indices_by_feature`), evaluates missing→LEFT and missing→RIGHT plus the `threshold=+∞` all-missing-right candidate (`node_split_best`, `_splitter.pyx:430-519`), and records the better direction as a per-split-node `missing_go_to_left` flag (in `NodeMeta`, extracted into `FittedDecisionTree*::missing_go_to_left`, sklearn `tree_.missing_go_to_left`, `_tree.pyx:746`). `fn partition_with_missing` routes NaN at fit-partition and `fn traverse_tree` routes NaN at predict to that direction (`_apply_dense`, `_tree.pyx:1015-1025`), eliminating the #2277 `(n,0)`-split stack overflow. **Non-test consumers**: `DecisionTreeClassifier`/`Regressor` `fit`/`predict` (re-exported at the crate root + the `RsDecisionTreeClassifier` PyO3 registration), and `BaggingClassifier`/`BaggingRegressor` (build DecisionTree base learners ⇒ inherit the fix, no longer overflow). Pinned by `divergence_tree_missing_values.rs` (clf/reg missing→left + missing→right + `threshold=+∞` deep tree + multi-feature, threshold/direction/predict matching live sklearn 1.5.2) and the rewritten `divergence_nan_stack_overflow.rs` (#2277 fit-succeeds + predict parity). All-finite trees are byte-identical (the 348 lib + `divergence_decision_tree` oracle pins stay green; `clf_all_finite_byte_identical_oracle`). The ExtraTree / random-splitter path does NOT support missing values (sklearn `_splitter.pyx:834`) — out of scope here. |
46
47use ferrolearn_core::error::FerroError;
48use ferrolearn_core::introspection::{HasClasses, HasFeatureImportances};
49use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
50use ferrolearn_core::traits::{Fit, Predict};
51use ndarray::{Array1, Array2};
52use num_traits::{Float, FromPrimitive, ToPrimitive};
53use rand::SeedableRng;
54use rand::rngs::StdRng;
55use rand::seq::index::sample as rand_sample_indices;
56use serde::{Deserialize, Serialize};
57
58// ---------------------------------------------------------------------------
59// Splitting criterion enums
60// ---------------------------------------------------------------------------
61
62/// Splitting criterion for classification trees.
63///
64/// Mirrors `CRITERIA_CLF` in `sklearn/tree/_classes.py:71-75`:
65/// `{"gini": Gini, "log_loss": Entropy, "entropy": Entropy}` — `log_loss` is an
66/// alias for `entropy` (both map to `_criterion.Entropy`), so [`Self::LogLoss`]
67/// uses the identical Shannon-entropy node-impurity formula as [`Self::Entropy`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum ClassificationCriterion {
70    /// Gini impurity, `1 − Σ_c p_c²` (`_criterion.pyx` `Gini`).
71    Gini,
72    /// Shannon entropy, `−Σ_c p_c·ln(p_c)` (natural log; `_criterion.pyx:655`).
73    Entropy,
74    /// `log_loss` — an alias for [`Self::Entropy`] (`_classes.py:73`,
75    /// `"log_loss": _criterion.Entropy`). Produces byte-identical trees to
76    /// [`Self::Entropy`]; uses the same `−Σ_c p_c·ln(p_c)` impurity.
77    LogLoss,
78}
79
80/// Splitting criterion for regression trees.
81///
82/// Mirrors `CRITERIA_REG` in `sklearn/tree/_classes.py:76-81`:
83/// `{"squared_error": MSE, "friedman_mse": FriedmanMSE, "absolute_error": MAE,
84/// "poisson": Poisson}`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86pub enum RegressionCriterion {
87    /// Mean squared error / `squared_error`, `sq_sum/N − (sum/N)²`
88    /// (`_criterion.pyx:1094`). Leaf value = mean.
89    Mse,
90    /// Friedman's MSE (`_criterion.pyx:1522` `FriedmanMSE`): node impurity is
91    /// the MSE variance, but the split improvement uses Friedman's proxy
92    /// `(n_R·sum_L − n_L·sum_R)² / (n_L·n_R·n_node)` (`impurity_improvement`,
93    /// `_criterion.pyx:1557-1574`). Leaf value = mean.
94    FriedmanMse,
95    /// Mean absolute error / `absolute_error` (`_criterion.pyx:1194` `MAE`):
96    /// node impurity `(1/n)·Σ|y_i − median(y)|` (`_criterion.pyx:1450-1472`).
97    /// Leaf value = **median** (`node_value`, `_criterion.pyx:1419-1423`).
98    AbsoluteError,
99    /// Half-Poisson deviance / `poisson` (`_criterion.pyx:1577` `Poisson`):
100    /// node impurity `(1/n)·Σ y_i·ln(y_i/mean)` with `0·ln0 = 0`
101    /// (`_criterion.pyx:1598-1708`). Leaf value = mean. Requires `y_i ≥ 0` and
102    /// `Σy > 0` (`_classes.py:267-277`).
103    Poisson,
104}
105
106// ---------------------------------------------------------------------------
107// Class weighting (classifier only)
108// ---------------------------------------------------------------------------
109
110/// Per-class weighting for [`DecisionTreeClassifier`].
111///
112/// Mirrors `sklearn.tree.DecisionTreeClassifier`'s `class_weight` parameter
113/// (`sklearn/tree/_classes.py:801-820`, constraint
114/// `{dict, list, 'balanced', None}`, `_classes.py:942`). sklearn expands
115/// `class_weight` to PER-SAMPLE weights via
116/// `compute_sample_weight(class_weight, y)` and folds them into the tree's
117/// `sample_weight` (`_classes.py:310-367`); every weighted quantity (node class
118/// counts, gini/entropy, the leaf `value_`/`predict_proba`, and the
119/// `min_weight_fraction_leaf` gate) is then computed on those weights.
120///
121/// Mirrors `ferrolearn_linear::svm::ClassWeight` for cross-estimator
122/// consistency, but is defined locally (no cross-crate import). `DecisionTreeRegressor`
123/// has NO `class_weight` (sklearn `_classes.py:1317`), so this lives only on the
124/// classifier.
125#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126pub enum ClassWeight<F> {
127    /// Uniform weights (all classes weighted `1.0`). The default
128    /// (`class_weight=None`). Produces a byte-identical tree to the unweighted
129    /// build.
130    #[default]
131    None,
132    /// Balanced weights `n_samples / (n_classes · count_c)` per class `c`,
133    /// matching `sklearn.utils.compute_class_weight("balanced", ...)`
134    /// (`class_weight.py:72`: `n_samples / (n_classes * np.bincount(y))`).
135    Balanced,
136    /// Explicit class-label → weight map. Classes absent from the map default to
137    /// `1.0`, matching the dict branch of `compute_class_weight`
138    /// (`class_weight.py:74-86`).
139    Explicit(Vec<(usize, F)>),
140}
141
142/// Compute the expanded per-class weight vector aligned to `classes`
143/// (sorted ascending, matching sklearn's `classes_ = np.unique(y)`).
144///
145/// Faithful to `sklearn.utils.compute_class_weight`
146/// (`sklearn/utils/class_weight.py:20-94`):
147/// - `None` → all `1.0` (`class_weight.py:61-63`).
148/// - `Balanced` → `n_samples / (n_classes · count_c)` per class `c`, where
149///   `count_c` is the number of samples with label `c`
150///   (`recip_freq = len(y) / (len(le.classes_) * np.bincount(y_ind))`,
151///   `class_weight.py:72`).
152/// - `Explicit(map)` → `1.0` default, overridden by the map entries matched by
153///   class label (`class_weight.py:74-86`).
154///
155/// `classes` is the sorted unique label set; `y` is the per-sample label array.
156/// Mirrors `ferrolearn_linear::svm::compute_class_weight` exactly.
157fn compute_class_weight<F: Float>(cw: &ClassWeight<F>, classes: &[usize], y: &[usize]) -> Vec<F> {
158    match cw {
159        ClassWeight::None => vec![F::one(); classes.len()],
160        ClassWeight::Balanced => {
161            let n_samples = F::from(y.len()).unwrap_or_else(F::zero);
162            let n_classes = F::from(classes.len()).unwrap_or_else(F::one);
163            classes
164                .iter()
165                .map(|&c| {
166                    let count = y.iter().filter(|&&label| label == c).count();
167                    let count_f = F::from(count).unwrap_or_else(F::one);
168                    if count_f > F::zero() {
169                        n_samples / (n_classes * count_f)
170                    } else {
171                        F::one()
172                    }
173                })
174                .collect()
175        }
176        ClassWeight::Explicit(map) => classes
177            .iter()
178            .map(|&c| {
179                map.iter()
180                    .find(|(label, _)| *label == c)
181                    .map_or_else(F::one, |(_, w)| *w)
182            })
183            .collect(),
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Node representation (flat vec for cache efficiency)
189// ---------------------------------------------------------------------------
190
191/// A single node in the decision tree, stored in a flat `Vec` for cache efficiency.
192///
193/// Internal nodes hold a split (feature index + threshold), while leaf nodes
194/// store a prediction value and optional class distribution (for classifiers).
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub enum Node<F> {
197    /// An internal split node.
198    Split {
199        /// Feature index used for the split.
200        feature: usize,
201        /// Threshold value; samples with `x[feature] <= threshold` go left.
202        threshold: F,
203        /// Index of the left child node in the flat vec.
204        left: usize,
205        /// Index of the right child node in the flat vec.
206        right: usize,
207        /// Weighted impurity decrease from this split (for feature importance).
208        impurity_decrease: F,
209        /// Number of samples that reached this node during training.
210        n_samples: usize,
211    },
212    /// A leaf node that stores a prediction.
213    Leaf {
214        /// Predicted value: class label (as F) for classifiers, mean for regressors.
215        value: F,
216        /// Class distribution (proportion of each class). Only used by classifiers.
217        class_distribution: Option<Vec<F>>,
218        /// Number of samples that reached this node during training.
219        n_samples: usize,
220    },
221}
222
223// ---------------------------------------------------------------------------
224// Internal config structs (to reduce argument counts)
225// ---------------------------------------------------------------------------
226
227/// Configuration parameters for tree building, bundled to reduce argument counts.
228///
229/// `min_samples_leaf` is the **effective** minimum leaf size — already the
230/// `max(min_samples_leaf, ceil(min_weight_fraction_leaf · n_total))` fold (the
231/// uniform-weight `min_weight_leaf` gate of `_splitter.pyx:470`).
232#[derive(Debug, Clone, Copy)]
233pub(crate) struct TreeParams {
234    pub(crate) max_depth: Option<usize>,
235    pub(crate) min_samples_split: usize,
236    pub(crate) min_samples_leaf: usize,
237}
238
239/// The `min_impurity_decrease` split gate, threaded through the depth-first
240/// build recursion separately from [`TreeParams`] so the (non-generic, forest-
241/// shared) `TreeParams` layout is unchanged.
242///
243/// `n_total` is the WHOLE tree's training-sample count (sklearn's `N` in
244/// `impurity_improvement`, `_criterion.pyx:199`), used to tree-normalize the
245/// improvement; `threshold` is the `min_impurity_decrease` hyperparameter
246/// (default `0.0`). A node becomes a leaf when
247/// `improvement + EPSILON < threshold` (`_tree.pyx:284`).
248#[derive(Debug, Clone, Copy)]
249pub(crate) struct ImpurityGate<F> {
250    pub(crate) n_total: usize,
251    pub(crate) threshold: F,
252}
253
254impl<F: Float> ImpurityGate<F> {
255    /// The no-op gate (`min_impurity_decrease = 0.0`) used by the forest
256    /// builders, which do not expose `min_impurity_decrease`. With a `0.0`
257    /// threshold the gate never rejects a split (any improvement
258    /// `>= -EPSILON` passes), keeping forest trees byte-identical.
259    fn disabled(n_total: usize) -> Self {
260        Self {
261            n_total,
262            threshold: F::zero(),
263        }
264    }
265
266    /// Returns `true` when a split with tree-normalized `improvement` must be
267    /// rejected (node becomes a leaf): `improvement + EPSILON < threshold`
268    /// (`_tree.pyx:284`). `EPSILON = np.finfo('double').eps` (`_tree.pyx:63`);
269    /// `F::epsilon()` is exactly that constant for `f64`.
270    fn rejects(&self, improvement: F) -> bool {
271        improvement + F::epsilon() < self.threshold
272    }
273}
274
275/// Per-node side metadata recorded ONLY by the [`DecisionTreeClassifier`] /
276/// [`DecisionTreeRegressor`] builders (not the forest builders) when
277/// `ccp_alpha > 0`, indexed in lock-step with the flat `Vec<Node<F>>`.
278///
279/// Minimal cost-complexity pruning (`ccp_alpha`, Breiman weakest-link;
280/// `_tree.pyx::_cost_complexity_prune`) needs, for EVERY node `t` (internal or
281/// leaf), the node's own impurity and sample count to form the resubstitution
282/// risk `R(t) = impurity(t) · n_t / N` (sklearn `r_node`, `_tree.pyx:1711`), and
283/// — when an internal node is collapsed into a leaf — that node's own
284/// prediction value and class distribution (sklearn copies the original node's
285/// stored value into the pruned leaf). These cannot all be reconstructed from
286/// the children alone (the `absolute_error` median is not the mean of child
287/// medians), so the builder records them here.
288#[derive(Debug, Clone)]
289struct NodeMeta<F> {
290    /// The node's own impurity (gini/entropy for classifiers; MSE/MAE/poisson
291    /// for regressors).
292    impurity: F,
293    /// The node's own sample count (sklearn `n_node_samples`).
294    n_samples: usize,
295    /// The node's own collapse prediction (majority class for classifiers,
296    /// mean/median for regressors) — the leaf value when this node is pruned.
297    value: F,
298    /// The node's own class distribution (classifier only) — the collapsed
299    /// leaf's `predict_proba` row when pruned. `None` for regressors.
300    distribution: Option<Vec<F>>,
301    /// For a SPLIT node, the direction a *missing* (NaN) value at the split
302    /// feature is routed: `true` ⇒ left child, `false` ⇒ right child. `false`
303    /// for leaves and for splits on a feature with no missing values.
304    ///
305    /// Mirrors sklearn's per-split-node `tree_.missing_go_to_left`
306    /// (`_tree.pyx:746-747,1017-1021`). The estimator builders ALWAYS record
307    /// `NodeMeta` (so this travels index-aligned with the flat `Vec<Node<F>>`
308    /// through best-first serialization and `ccp_alpha` pruning); the
309    /// `FittedDecisionTree*` structs extract it into their `missing_go_to_left`
310    /// vector for NaN-aware traversal. The forest builders pass `None` (no
311    /// `NodeMeta`), so their trees and the shared `Node::Split` enum are
312    /// byte-identical / unchanged.
313    missing_go_to_left: bool,
314}
315
316/// Data references for classification tree building.
317struct ClassificationData<'a, F> {
318    x: &'a Array2<F>,
319    y: &'a [usize],
320    n_classes: usize,
321    /// Fixed feature subset for the entire tree (used by Bagging-style
322    /// per-tree feature subsampling). Mutually exclusive with
323    /// [`Self::max_features_per_split`].
324    feature_indices: Option<&'a [usize]>,
325    /// When set, every split samples a fresh random subset of this many
326    /// features (per-split feature sampling, the Breiman 2001 RandomForest
327    /// behaviour and what scikit-learn does).
328    max_features_per_split: Option<usize>,
329    criterion: ClassificationCriterion,
330    /// Per-sample weights (indexed by the ORIGINAL sample index, parallel to
331    /// `y`), the expanded `class_weight` of `_classes.py:310-367`. `None` ⇒
332    /// uniform weights ⇒ the integer-count build path runs unchanged
333    /// (byte-identical). `Some(w)` ⇒ node class counts, gini/entropy, the leaf
334    /// distribution, and the `min_weight_fraction_leaf` gate are all WEIGHTED.
335    /// The forest/extra-trees builders always pass `None`.
336    sample_weight: Option<&'a [F]>,
337    /// `min_weight_leaf = min_weight_fraction_leaf · Σ sample_weight`
338    /// (`_classes.py:373`), the WEIGHTED per-child leaf-mass gate used only on
339    /// the weighted (`sample_weight = Some`) path. A split is rejected unless
340    /// each child's weighted mass `≥ min_weight_leaf` (`_splitter.pyx:470`).
341    /// `0.0` (the default / unweighted path) never rejects.
342    min_weight_leaf: F,
343}
344
345/// Data references for regression tree building.
346struct RegressionData<'a, F> {
347    x: &'a Array2<F>,
348    y: &'a Array1<F>,
349    feature_indices: Option<&'a [usize]>,
350    /// See [`ClassificationData::max_features_per_split`].
351    max_features_per_split: Option<usize>,
352    criterion: RegressionCriterion,
353}
354
355// ---------------------------------------------------------------------------
356// DecisionTreeClassifier
357// ---------------------------------------------------------------------------
358
359/// CART decision tree classifier.
360///
361/// Builds a binary tree by recursively finding the feature and threshold that
362/// maximises the reduction in the chosen impurity criterion (Gini or Entropy).
363///
364/// # Type Parameters
365///
366/// - `F`: The floating-point type (`f32` or `f64`).
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct DecisionTreeClassifier<F> {
369    /// Maximum depth of the tree. `None` means unlimited.
370    pub max_depth: Option<usize>,
371    /// Minimum number of samples required to split an internal node.
372    pub min_samples_split: usize,
373    /// Minimum number of samples required in a leaf node.
374    pub min_samples_leaf: usize,
375    /// Minimum weighted fraction of the total sample weight required at a leaf.
376    ///
377    /// Mirrors sklearn's `min_weight_fraction_leaf` (`_classes.py:946`,
378    /// default `0.0`). For uniform sample weights the effective minimum leaf
379    /// size becomes `max(min_samples_leaf, ceil(min_weight_fraction_leaf · N))`
380    /// where `N` is the total training-sample count (`_classes.py:371`,
381    /// `_splitter.pyx:470`).
382    pub min_weight_fraction_leaf: F,
383    /// Minimum tree-normalized weighted impurity decrease required to split a
384    /// node.
385    ///
386    /// Mirrors sklearn's `min_impurity_decrease` (`_classes.py:946`, default
387    /// `0.0`). A node is made a leaf when the split's improvement
388    /// `N_t/N·(parent − N_tL/N_t·imp_L − N_tR/N_t·imp_R)` satisfies
389    /// `improvement + EPSILON < min_impurity_decrease` (`_tree.pyx:284`).
390    pub min_impurity_decrease: F,
391    /// Complexity parameter for Minimal Cost-Complexity Pruning.
392    ///
393    /// Mirrors sklearn's `ccp_alpha` (`_classes.py:946`, default `0.0`,
394    /// `Interval(Real, 0.0, None, closed="left")`, `_classes.py:123`). After the
395    /// tree is grown, the subtree with the largest cost complexity that is
396    /// smaller than `ccp_alpha` is chosen (Breiman weakest-link pruning,
397    /// `_tree.pyx::_cost_complexity_prune`). `0.0` ⇒ no pruning.
398    pub ccp_alpha: F,
399    /// Maximum number of leaf nodes. `None` ⇒ unlimited (depth-first growth).
400    ///
401    /// Mirrors sklearn's `max_leaf_nodes` (`_classes.py:946`, default `None`,
402    /// `Interval(Integral, 2, None, closed="left")`, `_classes.py:121`). When
403    /// `Some(k)`, the tree is grown best-first (highest impurity improvement
404    /// expanded first, `BestFirstTreeBuilder`, `_tree.pyx:407`) until it has `k`
405    /// leaves (`2k−1` nodes) or no expandable frontier node remains. `None`
406    /// keeps the byte-identical depth-first build.
407    pub max_leaf_nodes: Option<usize>,
408    /// Splitting criterion.
409    pub criterion: ClassificationCriterion,
410    /// Per-class weighting (sklearn `class_weight`, `_classes.py:801`, default
411    /// `None`). Expanded to per-sample weights at fit and folded into every
412    /// weighted quantity (node class counts, gini/entropy, the leaf
413    /// distribution / `predict_proba`, the `min_weight_fraction_leaf` gate).
414    /// [`ClassWeight::None`] (default) ⇒ a byte-identical unweighted tree.
415    pub class_weight: ClassWeight<F>,
416    _marker: std::marker::PhantomData<F>,
417}
418
419impl<F: Float> DecisionTreeClassifier<F> {
420    /// Create a new `DecisionTreeClassifier` with default settings.
421    ///
422    /// Defaults: `max_depth = None`, `min_samples_split = 2`,
423    /// `min_samples_leaf = 1`, `min_weight_fraction_leaf = 0.0`,
424    /// `min_impurity_decrease = 0.0`, `ccp_alpha = 0.0`,
425    /// `max_leaf_nodes = None`, `criterion = Gini`,
426    /// `class_weight = ClassWeight::None`.
427    #[must_use]
428    pub fn new() -> Self {
429        Self {
430            max_depth: None,
431            min_samples_split: 2,
432            min_samples_leaf: 1,
433            min_weight_fraction_leaf: F::zero(),
434            min_impurity_decrease: F::zero(),
435            ccp_alpha: F::zero(),
436            max_leaf_nodes: None,
437            criterion: ClassificationCriterion::Gini,
438            class_weight: ClassWeight::None,
439            _marker: std::marker::PhantomData,
440        }
441    }
442
443    /// Set the maximum tree depth.
444    #[must_use]
445    pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
446        self.max_depth = max_depth;
447        self
448    }
449
450    /// Set the minimum number of samples required to split a node.
451    #[must_use]
452    pub fn with_min_samples_split(mut self, min_samples_split: usize) -> Self {
453        self.min_samples_split = min_samples_split;
454        self
455    }
456
457    /// Set the minimum number of samples required in a leaf node.
458    #[must_use]
459    pub fn with_min_samples_leaf(mut self, min_samples_leaf: usize) -> Self {
460        self.min_samples_leaf = min_samples_leaf;
461        self
462    }
463
464    /// Set the minimum weighted fraction of the total sample weight required at
465    /// a leaf (sklearn `min_weight_fraction_leaf`, `_classes.py:946`).
466    #[must_use]
467    pub fn with_min_weight_fraction_leaf(mut self, min_weight_fraction_leaf: F) -> Self {
468        self.min_weight_fraction_leaf = min_weight_fraction_leaf;
469        self
470    }
471
472    /// Set the minimum tree-normalized weighted impurity decrease required to
473    /// split a node (sklearn `min_impurity_decrease`, `_classes.py:946`).
474    #[must_use]
475    pub fn with_min_impurity_decrease(mut self, min_impurity_decrease: F) -> Self {
476        self.min_impurity_decrease = min_impurity_decrease;
477        self
478    }
479
480    /// Set the complexity parameter for Minimal Cost-Complexity Pruning
481    /// (sklearn `ccp_alpha`, `_classes.py:946`, default `0.0`).
482    #[must_use]
483    pub fn with_ccp_alpha(mut self, ccp_alpha: F) -> Self {
484        self.ccp_alpha = ccp_alpha;
485        self
486    }
487
488    /// Set the maximum number of leaf nodes (sklearn `max_leaf_nodes`,
489    /// `_classes.py:946`, default `None`). `Some(k)` switches the build to
490    /// best-first growth (`BestFirstTreeBuilder`, `_tree.pyx:407`).
491    #[must_use]
492    pub fn with_max_leaf_nodes(mut self, max_leaf_nodes: Option<usize>) -> Self {
493        self.max_leaf_nodes = max_leaf_nodes;
494        self
495    }
496
497    /// Set the splitting criterion.
498    #[must_use]
499    pub fn with_criterion(mut self, criterion: ClassificationCriterion) -> Self {
500        self.criterion = criterion;
501        self
502    }
503
504    /// Set the per-class weighting (sklearn `class_weight`, `_classes.py:801`).
505    /// [`ClassWeight::None`] (default) leaves every class at `1.0`;
506    /// [`ClassWeight::Balanced`] uses `n_samples / (n_classes · count_c)`;
507    /// [`ClassWeight::Explicit`] takes a per-class-label weight map
508    /// (`compute_class_weight`, `class_weight.py:20`). The weights are expanded
509    /// to per-sample weights at fit and fold into every weighted node quantity.
510    #[must_use]
511    pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
512        self.class_weight = class_weight;
513        self
514    }
515}
516
517impl<F: Float> Default for DecisionTreeClassifier<F> {
518    fn default() -> Self {
519        Self::new()
520    }
521}
522
523// ---------------------------------------------------------------------------
524// FittedDecisionTreeClassifier
525// ---------------------------------------------------------------------------
526
527/// A fitted CART decision tree classifier.
528///
529/// Stores the learned tree as a flat `Vec<Node<F>>` for cache-friendly traversal.
530/// Implements [`Predict`] for generating class predictions and
531/// [`HasFeatureImportances`] for inspecting per-feature importance scores.
532#[derive(Debug, Clone)]
533pub struct FittedDecisionTreeClassifier<F> {
534    /// Flat node storage; index 0 is the root.
535    nodes: Vec<Node<F>>,
536    /// Sorted unique class labels observed during training.
537    classes: Vec<usize>,
538    /// Number of features the model was trained on.
539    n_features: usize,
540    /// Per-feature importance scores (normalised to sum to 1).
541    feature_importances: Array1<F>,
542    /// Per-node missing (NaN) routing direction, index-aligned with `nodes`
543    /// (`true` ⇒ left child). sklearn's `tree_.missing_go_to_left`
544    /// (`_tree.pyx:746`). Consulted by NaN-aware traversal at predict; for an
545    /// all-finite tree every entry is `false` and traversal is unchanged.
546    missing_go_to_left: Vec<bool>,
547}
548
549impl<F: Float + Send + Sync + 'static> FittedDecisionTreeClassifier<F> {
550    /// Returns a reference to the flat node storage of the tree.
551    #[must_use]
552    pub fn nodes(&self) -> &[Node<F>] {
553        &self.nodes
554    }
555
556    /// Returns the number of features the model was trained on.
557    #[must_use]
558    pub fn n_features(&self) -> usize {
559        self.n_features
560    }
561
562    /// Predict class probabilities for each sample.
563    ///
564    /// Returns a 2-D array of shape `(n_samples, n_classes)`.
565    ///
566    /// # Errors
567    ///
568    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
569    /// not match the training data.
570    pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
571        if x.ncols() != self.n_features {
572            return Err(FerroError::ShapeMismatch {
573                expected: vec![self.n_features],
574                actual: vec![x.ncols()],
575                context: "number of features must match fitted model".into(),
576            });
577        }
578        reject_infinite(x)?;
579        let n_samples = x.nrows();
580        let n_classes = self.classes.len();
581        let mut proba = Array2::zeros((n_samples, n_classes));
582        for i in 0..n_samples {
583            let row = x.row(i);
584            let leaf = traverse_tree(&self.nodes, &self.missing_go_to_left, &row);
585            if let Node::Leaf {
586                class_distribution: Some(ref dist),
587                ..
588            } = self.nodes[leaf]
589            {
590                for (j, &p) in dist.iter().enumerate() {
591                    proba[[i, j]] = p;
592                }
593            }
594        }
595        Ok(proba)
596    }
597
598    /// Mean accuracy on the given test data and labels.
599    /// Equivalent to sklearn's `ClassifierMixin.score`.
600    ///
601    /// # Errors
602    ///
603    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()` or
604    /// the feature count does not match the training data.
605    pub fn score(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<F, FerroError> {
606        if x.nrows() != y.len() {
607            return Err(FerroError::ShapeMismatch {
608                expected: vec![x.nrows()],
609                actual: vec![y.len()],
610                context: "y length must match number of samples in X".into(),
611            });
612        }
613        let preds = self.predict(x)?;
614        Ok(crate::mean_accuracy(&preds, y))
615    }
616
617    /// Element-wise log of [`predict_proba`](Self::predict_proba). Mirrors
618    /// sklearn's `ClassifierMixin.predict_log_proba`.
619    ///
620    /// # Errors
621    ///
622    /// Forwards any error from [`predict_proba`](Self::predict_proba).
623    pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
624        let proba = self.predict_proba(x)?;
625        Ok(crate::log_proba(&proba))
626    }
627}
628
629impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>> for DecisionTreeClassifier<F> {
630    type Fitted = FittedDecisionTreeClassifier<F>;
631    type Error = FerroError;
632
633    /// Fit the decision tree classifier on the training data.
634    ///
635    /// # Errors
636    ///
637    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
638    /// numbers of samples.
639    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
640    /// Returns [`FerroError::InvalidParameter`] if hyperparameters are invalid.
641    fn fit(
642        &self,
643        x: &Array2<F>,
644        y: &Array1<usize>,
645    ) -> Result<FittedDecisionTreeClassifier<F>, FerroError> {
646        let (n_samples, n_features) = x.dim();
647
648        if n_samples != y.len() {
649            return Err(FerroError::ShapeMismatch {
650                expected: vec![n_samples],
651                actual: vec![y.len()],
652                context: "y length must match number of samples in X".into(),
653            });
654        }
655        if n_samples == 0 {
656            return Err(FerroError::InsufficientSamples {
657                required: 1,
658                actual: 0,
659                context: "DecisionTreeClassifier requires at least one sample".into(),
660            });
661        }
662        if self.min_samples_split < 2 {
663            return Err(FerroError::InvalidParameter {
664                name: "min_samples_split".into(),
665                reason: "must be at least 2".into(),
666            });
667        }
668        if self.min_samples_leaf < 1 {
669            return Err(FerroError::InvalidParameter {
670                name: "min_samples_leaf".into(),
671                reason: "must be at least 1".into(),
672            });
673        }
674        // sklearn rejects ±Inf in X even with `force_all_finite=False`
675        // (NaN is allowed by the missing-value path; Inf is fatal).
676        reject_infinite(x)?;
677
678        // Determine unique classes.
679        let mut classes: Vec<usize> = y.iter().copied().collect();
680        classes.sort_unstable();
681        classes.dedup();
682        let n_classes = classes.len();
683
684        // Map class labels to indices 0..n_classes.
685        let y_mapped: Vec<usize> = y
686            .iter()
687            .map(|&c| classes.iter().position(|&cl| cl == c).unwrap())
688            .collect();
689
690        let indices: Vec<usize> = (0..n_samples).collect();
691
692        // Expand `class_weight` into PER-SAMPLE weights, the
693        // `compute_sample_weight(class_weight, y)` of `_classes.py:310-367`:
694        // `sample_weight[i] = class_weight_[y_i]`. `None` ⇒ uniform ⇒ the
695        // integer-count build path runs unchanged (byte-identical).
696        let y_vec: Vec<usize> = y.iter().copied().collect();
697        let per_class_weight = compute_class_weight(&self.class_weight, &classes, &y_vec);
698        let use_weights = self.class_weight != ClassWeight::None;
699        let sample_weight: Option<Vec<F>> = if use_weights {
700            Some(y_mapped.iter().map(|&c| per_class_weight[c]).collect())
701        } else {
702            None
703        };
704        // `min_weight_leaf = min_weight_fraction_leaf · Σ sample_weight`
705        // (`_classes.py:373`). On the unweighted path the fold below into
706        // `effective_min_samples_leaf` handles `min_weight_fraction_leaf`
707        // (uniform weights); on the weighted path the weighted child gate uses
708        // this `min_weight_leaf` directly.
709        let total_weight: F = sample_weight.as_ref().map_or_else(
710            || F::from(n_samples).unwrap_or_else(F::one),
711            |w| w.iter().fold(F::zero(), |a, &b| a + b),
712        );
713        let min_weight_leaf = self.min_weight_fraction_leaf * total_weight;
714
715        let data = ClassificationData {
716            x,
717            y: &y_mapped,
718            n_classes,
719            feature_indices: None,
720            max_features_per_split: None,
721            criterion: self.criterion,
722            sample_weight: sample_weight.as_deref(),
723            min_weight_leaf,
724        };
725        // Fold `min_weight_fraction_leaf` into the effective per-child minimum
726        // leaf size (uniform weights, `_classes.py:371` / `_splitter.pyx:470`).
727        // On the WEIGHTED path the uniform fold does not apply (the weighted
728        // `min_weight_leaf` gate above replaces it), so keep the raw
729        // `min_samples_leaf`.
730        let params = TreeParams {
731            max_depth: self.max_depth,
732            min_samples_split: self.min_samples_split,
733            min_samples_leaf: if use_weights {
734                self.min_samples_leaf
735            } else {
736                effective_min_samples_leaf(
737                    self.min_samples_leaf,
738                    self.min_weight_fraction_leaf,
739                    n_samples,
740                )
741            },
742        };
743        let gate = ImpurityGate {
744            n_total: n_samples,
745            threshold: self.min_impurity_decrease,
746        };
747
748        // The estimator builders ALWAYS record `NodeMeta` (index-aligned with
749        // `nodes`) so the per-split-node `missing_go_to_left` flag travels
750        // through best-first serialization and `ccp_alpha` pruning. Forest
751        // builders still pass `None` (no meta) and stay byte-identical.
752        let prune = self.ccp_alpha > F::zero();
753        let mut nodes: Vec<Node<F>> = Vec::new();
754        let mut meta: Vec<NodeMeta<F>> = Vec::new();
755        if let Some(max_leaf_nodes) = self.max_leaf_nodes {
756            // Best-first growth (`BestFirstTreeBuilder`, `_tree.pyx:407`):
757            // expand the frontier node with the highest impurity improvement
758            // until `max_leaf_nodes` leaves are reached.
759            build_classification_tree_best_first(
760                &data,
761                &indices,
762                &mut nodes,
763                Some(&mut meta),
764                &params,
765                &gate,
766                max_leaf_nodes,
767            );
768        } else {
769            build_classification_tree(
770                &data,
771                &indices,
772                &mut nodes,
773                Some(&mut meta),
774                0,
775                &params,
776                &gate,
777                None,
778            );
779        }
780
781        if prune {
782            let (pruned_nodes, pruned_meta) = prune_ccp(&nodes, &meta, n_samples, self.ccp_alpha);
783            nodes = pruned_nodes;
784            meta = pruned_meta;
785        }
786
787        let missing_go_to_left = missing_directions(&meta);
788        let feature_importances = compute_feature_importances(&nodes, n_features, n_samples);
789
790        Ok(FittedDecisionTreeClassifier {
791            nodes,
792            classes,
793            n_features,
794            feature_importances,
795            missing_go_to_left,
796        })
797    }
798}
799
800/// Extract the per-node `missing_go_to_left` flags (index-aligned with the flat
801/// node vector) from the build's `NodeMeta` side table.
802fn missing_directions<F>(meta: &[NodeMeta<F>]) -> Vec<bool> {
803    meta.iter().map(|m| m.missing_go_to_left).collect()
804}
805
806impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedDecisionTreeClassifier<F> {
807    type Output = Array1<usize>;
808    type Error = FerroError;
809
810    /// Predict class labels for the given feature matrix.
811    ///
812    /// # Errors
813    ///
814    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
815    /// not match the fitted model.
816    fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
817        if x.ncols() != self.n_features {
818            return Err(FerroError::ShapeMismatch {
819                expected: vec![self.n_features],
820                actual: vec![x.ncols()],
821                context: "number of features must match fitted model".into(),
822            });
823        }
824        reject_infinite(x)?;
825        let n_samples = x.nrows();
826        let mut predictions = Array1::zeros(n_samples);
827        for i in 0..n_samples {
828            let row = x.row(i);
829            let leaf = traverse_tree(&self.nodes, &self.missing_go_to_left, &row);
830            if let Node::Leaf { value, .. } = self.nodes[leaf] {
831                predictions[i] = float_to_usize(value);
832            }
833        }
834        Ok(predictions)
835    }
836}
837
838impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F>
839    for FittedDecisionTreeClassifier<F>
840{
841    fn feature_importances(&self) -> &Array1<F> {
842        &self.feature_importances
843    }
844}
845
846impl<F: Float + Send + Sync + 'static> HasClasses for FittedDecisionTreeClassifier<F> {
847    fn classes(&self) -> &[usize] {
848        &self.classes
849    }
850
851    fn n_classes(&self) -> usize {
852        self.classes.len()
853    }
854}
855
856// Pipeline integration.
857impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> PipelineEstimator<F>
858    for DecisionTreeClassifier<F>
859{
860    fn fit_pipeline(
861        &self,
862        x: &Array2<F>,
863        y: &Array1<F>,
864    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
865        let y_usize: Array1<usize> = y.mapv(|v| v.to_usize().unwrap_or(0));
866        let fitted = self.fit(x, &y_usize)?;
867        Ok(Box::new(FittedClassifierPipelineAdapter(fitted)))
868    }
869}
870
871/// Adapter to make `FittedDecisionTreeClassifier<F>` work as a pipeline estimator.
872struct FittedClassifierPipelineAdapter<F: Float + Send + Sync + 'static>(
873    FittedDecisionTreeClassifier<F>,
874);
875
876impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> FittedPipelineEstimator<F>
877    for FittedClassifierPipelineAdapter<F>
878{
879    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
880        let preds = self.0.predict(x)?;
881        Ok(preds.mapv(|v| F::from_usize(v).unwrap_or_else(F::nan)))
882    }
883}
884
885// ---------------------------------------------------------------------------
886// DecisionTreeRegressor
887// ---------------------------------------------------------------------------
888
889/// CART decision tree regressor.
890///
891/// Builds a binary tree by recursively finding the feature and threshold that
892/// minimises the mean squared error of the split.
893///
894/// # Type Parameters
895///
896/// - `F`: The floating-point type (`f32` or `f64`).
897#[derive(Debug, Clone, Serialize, Deserialize)]
898pub struct DecisionTreeRegressor<F> {
899    /// Maximum depth of the tree. `None` means unlimited.
900    pub max_depth: Option<usize>,
901    /// Minimum number of samples required to split an internal node.
902    pub min_samples_split: usize,
903    /// Minimum number of samples required in a leaf node.
904    pub min_samples_leaf: usize,
905    /// Minimum weighted fraction of the total sample weight required at a leaf.
906    ///
907    /// Mirrors sklearn's `min_weight_fraction_leaf` (`_classes.py:1317`,
908    /// default `0.0`). For uniform sample weights the effective minimum leaf
909    /// size becomes `max(min_samples_leaf, ceil(min_weight_fraction_leaf · N))`
910    /// where `N` is the total training-sample count (`_classes.py:371`,
911    /// `_splitter.pyx:470`).
912    pub min_weight_fraction_leaf: F,
913    /// Minimum tree-normalized weighted impurity decrease required to split a
914    /// node.
915    ///
916    /// Mirrors sklearn's `min_impurity_decrease` (`_classes.py:1317`, default
917    /// `0.0`). A node is made a leaf when the split's improvement
918    /// `N_t/N·(parent − N_tL/N_t·imp_L − N_tR/N_t·imp_R)` satisfies
919    /// `improvement + EPSILON < min_impurity_decrease` (`_tree.pyx:284`).
920    pub min_impurity_decrease: F,
921    /// Complexity parameter for Minimal Cost-Complexity Pruning.
922    ///
923    /// Mirrors sklearn's `ccp_alpha` (`_classes.py:1317`, default `0.0`,
924    /// `Interval(Real, 0.0, None, closed="left")`, `_classes.py:123`). After the
925    /// tree is grown, the subtree with the largest cost complexity that is
926    /// smaller than `ccp_alpha` is chosen (Breiman weakest-link pruning,
927    /// `_tree.pyx::_cost_complexity_prune`). `0.0` ⇒ no pruning.
928    pub ccp_alpha: F,
929    /// Maximum number of leaf nodes. `None` ⇒ unlimited (depth-first growth).
930    ///
931    /// Mirrors sklearn's `max_leaf_nodes` (`_classes.py:1317`, default `None`,
932    /// `Interval(Integral, 2, None, closed="left")`, `_classes.py:121`). When
933    /// `Some(k)`, the tree is grown best-first (highest impurity improvement
934    /// expanded first, `BestFirstTreeBuilder`, `_tree.pyx:407`) until it has `k`
935    /// leaves (`2k−1` nodes) or no expandable frontier node remains. `None`
936    /// keeps the byte-identical depth-first build.
937    pub max_leaf_nodes: Option<usize>,
938    /// Splitting criterion.
939    pub criterion: RegressionCriterion,
940    _marker: std::marker::PhantomData<F>,
941}
942
943impl<F: Float> DecisionTreeRegressor<F> {
944    /// Create a new `DecisionTreeRegressor` with default settings.
945    ///
946    /// Defaults: `max_depth = None`, `min_samples_split = 2`,
947    /// `min_samples_leaf = 1`, `min_weight_fraction_leaf = 0.0`,
948    /// `min_impurity_decrease = 0.0`, `ccp_alpha = 0.0`,
949    /// `max_leaf_nodes = None`, `criterion = MSE`.
950    #[must_use]
951    pub fn new() -> Self {
952        Self {
953            max_depth: None,
954            min_samples_split: 2,
955            min_samples_leaf: 1,
956            min_weight_fraction_leaf: F::zero(),
957            min_impurity_decrease: F::zero(),
958            ccp_alpha: F::zero(),
959            max_leaf_nodes: None,
960            criterion: RegressionCriterion::Mse,
961            _marker: std::marker::PhantomData,
962        }
963    }
964
965    /// Set the maximum tree depth.
966    #[must_use]
967    pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
968        self.max_depth = max_depth;
969        self
970    }
971
972    /// Set the minimum number of samples required to split a node.
973    #[must_use]
974    pub fn with_min_samples_split(mut self, min_samples_split: usize) -> Self {
975        self.min_samples_split = min_samples_split;
976        self
977    }
978
979    /// Set the minimum number of samples required in a leaf node.
980    #[must_use]
981    pub fn with_min_samples_leaf(mut self, min_samples_leaf: usize) -> Self {
982        self.min_samples_leaf = min_samples_leaf;
983        self
984    }
985
986    /// Set the minimum weighted fraction of the total sample weight required at
987    /// a leaf (sklearn `min_weight_fraction_leaf`, `_classes.py:1317`).
988    #[must_use]
989    pub fn with_min_weight_fraction_leaf(mut self, min_weight_fraction_leaf: F) -> Self {
990        self.min_weight_fraction_leaf = min_weight_fraction_leaf;
991        self
992    }
993
994    /// Set the minimum tree-normalized weighted impurity decrease required to
995    /// split a node (sklearn `min_impurity_decrease`, `_classes.py:1317`).
996    #[must_use]
997    pub fn with_min_impurity_decrease(mut self, min_impurity_decrease: F) -> Self {
998        self.min_impurity_decrease = min_impurity_decrease;
999        self
1000    }
1001
1002    /// Set the complexity parameter for Minimal Cost-Complexity Pruning
1003    /// (sklearn `ccp_alpha`, `_classes.py:1317`, default `0.0`).
1004    #[must_use]
1005    pub fn with_ccp_alpha(mut self, ccp_alpha: F) -> Self {
1006        self.ccp_alpha = ccp_alpha;
1007        self
1008    }
1009
1010    /// Set the maximum number of leaf nodes (sklearn `max_leaf_nodes`,
1011    /// `_classes.py:1317`, default `None`). `Some(k)` switches the build to
1012    /// best-first growth (`BestFirstTreeBuilder`, `_tree.pyx:407`).
1013    #[must_use]
1014    pub fn with_max_leaf_nodes(mut self, max_leaf_nodes: Option<usize>) -> Self {
1015        self.max_leaf_nodes = max_leaf_nodes;
1016        self
1017    }
1018
1019    /// Set the splitting criterion.
1020    #[must_use]
1021    pub fn with_criterion(mut self, criterion: RegressionCriterion) -> Self {
1022        self.criterion = criterion;
1023        self
1024    }
1025}
1026
1027impl<F: Float> Default for DecisionTreeRegressor<F> {
1028    fn default() -> Self {
1029        Self::new()
1030    }
1031}
1032
1033// ---------------------------------------------------------------------------
1034// FittedDecisionTreeRegressor
1035// ---------------------------------------------------------------------------
1036
1037/// A fitted CART decision tree regressor.
1038///
1039/// Stores the learned tree as a flat `Vec<Node<F>>` for cache-friendly traversal.
1040#[derive(Debug, Clone)]
1041pub struct FittedDecisionTreeRegressor<F> {
1042    /// Flat node storage; index 0 is the root.
1043    nodes: Vec<Node<F>>,
1044    /// Number of features the model was trained on.
1045    n_features: usize,
1046    /// Per-feature importance scores (normalised to sum to 1).
1047    feature_importances: Array1<F>,
1048    /// Per-node missing (NaN) routing direction, index-aligned with `nodes`
1049    /// (`true` ⇒ left child). sklearn's `tree_.missing_go_to_left`
1050    /// (`_tree.pyx:746`). All-`false` for an all-finite tree (traversal
1051    /// unchanged).
1052    missing_go_to_left: Vec<bool>,
1053}
1054
1055impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<F>> for DecisionTreeRegressor<F> {
1056    type Fitted = FittedDecisionTreeRegressor<F>;
1057    type Error = FerroError;
1058
1059    /// Fit the decision tree regressor on the training data.
1060    ///
1061    /// # Errors
1062    ///
1063    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
1064    /// numbers of samples.
1065    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
1066    /// Returns [`FerroError::InvalidParameter`] if hyperparameters are invalid.
1067    fn fit(
1068        &self,
1069        x: &Array2<F>,
1070        y: &Array1<F>,
1071    ) -> Result<FittedDecisionTreeRegressor<F>, FerroError> {
1072        let (n_samples, n_features) = x.dim();
1073
1074        if n_samples != y.len() {
1075            return Err(FerroError::ShapeMismatch {
1076                expected: vec![n_samples],
1077                actual: vec![y.len()],
1078                context: "y length must match number of samples in X".into(),
1079            });
1080        }
1081        if n_samples == 0 {
1082            return Err(FerroError::InsufficientSamples {
1083                required: 1,
1084                actual: 0,
1085                context: "DecisionTreeRegressor requires at least one sample".into(),
1086            });
1087        }
1088        if self.min_samples_split < 2 {
1089            return Err(FerroError::InvalidParameter {
1090                name: "min_samples_split".into(),
1091                reason: "must be at least 2".into(),
1092            });
1093        }
1094        if self.min_samples_leaf < 1 {
1095            return Err(FerroError::InvalidParameter {
1096                name: "min_samples_leaf".into(),
1097                reason: "must be at least 1".into(),
1098            });
1099        }
1100        // sklearn rejects ±Inf in X even with `force_all_finite=False`
1101        // (NaN is allowed by the missing-value path; Inf is fatal).
1102        reject_infinite(x)?;
1103
1104        // Poisson requires non-negative targets with a strictly positive sum,
1105        // mirroring sklearn's check (`_classes.py:267-277`): negative y raises
1106        // "Some value(s) of y are negative which is not allowed for Poisson
1107        // regression."; a non-positive sum raises "Sum of y is not positive
1108        // which is necessary for Poisson regression."
1109        if self.criterion == RegressionCriterion::Poisson {
1110            if y.iter().any(|&v| v < F::zero()) {
1111                return Err(FerroError::InvalidParameter {
1112                    name: "y".into(),
1113                    reason: "Some value(s) of y are negative which is not allowed for Poisson \
1114                             regression."
1115                        .into(),
1116                });
1117            }
1118            let sum_y = y.iter().fold(F::zero(), |a, &b| a + b);
1119            if sum_y <= F::zero() {
1120                return Err(FerroError::InvalidParameter {
1121                    name: "y".into(),
1122                    reason: "Sum of y is not positive which is necessary for Poisson regression."
1123                        .into(),
1124                });
1125            }
1126        }
1127
1128        let indices: Vec<usize> = (0..n_samples).collect();
1129
1130        let data = RegressionData {
1131            x,
1132            y,
1133            feature_indices: None,
1134            max_features_per_split: None,
1135            criterion: self.criterion,
1136        };
1137        // Fold `min_weight_fraction_leaf` into the effective per-child minimum
1138        // leaf size (uniform weights, `_classes.py:371` / `_splitter.pyx:470`).
1139        let params = TreeParams {
1140            max_depth: self.max_depth,
1141            min_samples_split: self.min_samples_split,
1142            min_samples_leaf: effective_min_samples_leaf(
1143                self.min_samples_leaf,
1144                self.min_weight_fraction_leaf,
1145                n_samples,
1146            ),
1147        };
1148        let gate = ImpurityGate {
1149            n_total: n_samples,
1150            threshold: self.min_impurity_decrease,
1151        };
1152
1153        // Always record `NodeMeta` on the estimator path so the per-split-node
1154        // `missing_go_to_left` flag survives best-first serialization / pruning.
1155        let prune = self.ccp_alpha > F::zero();
1156        let mut nodes: Vec<Node<F>> = Vec::new();
1157        let mut meta: Vec<NodeMeta<F>> = Vec::new();
1158        if let Some(max_leaf_nodes) = self.max_leaf_nodes {
1159            // Best-first growth (`BestFirstTreeBuilder`, `_tree.pyx:407`).
1160            build_regression_tree_best_first(
1161                &data,
1162                &indices,
1163                &mut nodes,
1164                Some(&mut meta),
1165                &params,
1166                &gate,
1167                max_leaf_nodes,
1168            );
1169        } else {
1170            build_regression_tree(
1171                &data,
1172                &indices,
1173                &mut nodes,
1174                Some(&mut meta),
1175                0,
1176                &params,
1177                &gate,
1178                None,
1179            );
1180        }
1181
1182        if prune {
1183            let (pruned_nodes, pruned_meta) = prune_ccp(&nodes, &meta, n_samples, self.ccp_alpha);
1184            nodes = pruned_nodes;
1185            meta = pruned_meta;
1186        }
1187
1188        let missing_go_to_left = missing_directions(&meta);
1189        let feature_importances = compute_feature_importances(&nodes, n_features, n_samples);
1190
1191        Ok(FittedDecisionTreeRegressor {
1192            nodes,
1193            n_features,
1194            feature_importances,
1195            missing_go_to_left,
1196        })
1197    }
1198}
1199
1200impl<F: Float + Send + Sync + 'static> FittedDecisionTreeRegressor<F> {
1201    /// Returns a reference to the flat node storage of the tree.
1202    #[must_use]
1203    pub fn nodes(&self) -> &[Node<F>] {
1204        &self.nodes
1205    }
1206
1207    /// Returns the number of features the model was trained on.
1208    #[must_use]
1209    pub fn n_features(&self) -> usize {
1210        self.n_features
1211    }
1212
1213    /// R² coefficient of determination on the given test data.
1214    /// Equivalent to sklearn's `RegressorMixin.score`.
1215    ///
1216    /// # Errors
1217    ///
1218    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()` or
1219    /// the feature count does not match the training data.
1220    pub fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
1221        if x.nrows() != y.len() {
1222            return Err(FerroError::ShapeMismatch {
1223                expected: vec![x.nrows()],
1224                actual: vec![y.len()],
1225                context: "y length must match number of samples in X".into(),
1226            });
1227        }
1228        let preds = self.predict(x)?;
1229        Ok(crate::r2_score(&preds, y))
1230    }
1231}
1232
1233impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedDecisionTreeRegressor<F> {
1234    type Output = Array1<F>;
1235    type Error = FerroError;
1236
1237    /// Predict target values for the given feature matrix.
1238    ///
1239    /// # Errors
1240    ///
1241    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
1242    /// not match the fitted model.
1243    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
1244        if x.ncols() != self.n_features {
1245            return Err(FerroError::ShapeMismatch {
1246                expected: vec![self.n_features],
1247                actual: vec![x.ncols()],
1248                context: "number of features must match fitted model".into(),
1249            });
1250        }
1251        reject_infinite(x)?;
1252        let n_samples = x.nrows();
1253        let mut predictions = Array1::zeros(n_samples);
1254        for i in 0..n_samples {
1255            let row = x.row(i);
1256            let leaf = traverse_tree(&self.nodes, &self.missing_go_to_left, &row);
1257            if let Node::Leaf { value, .. } = self.nodes[leaf] {
1258                predictions[i] = value;
1259            }
1260        }
1261        Ok(predictions)
1262    }
1263}
1264
1265impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F> for FittedDecisionTreeRegressor<F> {
1266    fn feature_importances(&self) -> &Array1<F> {
1267        &self.feature_importances
1268    }
1269}
1270
1271// Pipeline integration.
1272impl<F: Float + Send + Sync + 'static> PipelineEstimator<F> for DecisionTreeRegressor<F> {
1273    fn fit_pipeline(
1274        &self,
1275        x: &Array2<F>,
1276        y: &Array1<F>,
1277    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
1278        let fitted = self.fit(x, y)?;
1279        Ok(Box::new(fitted))
1280    }
1281}
1282
1283impl<F: Float + Send + Sync + 'static> FittedPipelineEstimator<F>
1284    for FittedDecisionTreeRegressor<F>
1285{
1286    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
1287        self.predict(x)
1288    }
1289}
1290
1291// ---------------------------------------------------------------------------
1292// Internal: tree building helpers
1293// ---------------------------------------------------------------------------
1294
1295/// sklearn's constant-feature / equal-value split band.
1296///
1297/// Mirrors `FEATURE_THRESHOLD = 1e-7` in `sklearn/tree/_splitter.pyx:33`.
1298/// A feature whose sorted value spread over a node's samples is
1299/// `<= FEATURE_THRESHOLD` is considered constant (un-splittable), and a split
1300/// between adjacent sorted values is only considered when
1301/// `x[p] > x[p-1] + FEATURE_THRESHOLD` (`_splitter.pyx:405` and the per-pair
1302/// `next_p` skip). `1e-7` is exactly representable in both `f32` and `f64`, so
1303/// the `F::from` conversion is lossless; the `F::epsilon` fallback is defensive
1304/// and never taken for the supported `f32`/`f64` types.
1305fn feature_threshold<F: Float>() -> F {
1306    F::from(1e-7).unwrap_or_else(F::epsilon)
1307}
1308
1309/// Fold `min_weight_fraction_leaf` into the effective minimum leaf size for
1310/// uniform sample weights.
1311///
1312/// sklearn sets `min_weight_leaf = min_weight_fraction_leaf · N`
1313/// (`_classes.py:371`, uniform-weight branch) and the best-splitter rejects a
1314/// split whose child has `weighted_n_child < min_weight_leaf`
1315/// (`_splitter.pyx:470`). With uniform weights `weighted_n_child = n_child`, so
1316/// rejecting `n_child < min_weight_leaf` is equivalent to requiring
1317/// `n_child >= ceil(min_weight_leaf)`. The effective per-child minimum is then
1318/// `max(min_samples_leaf, ceil(min_weight_fraction_leaf · N))`.
1319fn effective_min_samples_leaf<F: Float>(
1320    min_samples_leaf: usize,
1321    min_weight_fraction_leaf: F,
1322    n_total: usize,
1323) -> usize {
1324    if min_weight_fraction_leaf <= F::zero() {
1325        return min_samples_leaf;
1326    }
1327    let min_weight_leaf = min_weight_fraction_leaf * F::from(n_total).unwrap_or_else(F::one);
1328    // ceil(min_weight_leaf), saturating into usize; defensive `map_or(0)` keeps
1329    // this panic-free (R-CODE-2) for the supported f32/f64 types.
1330    let ceil_weight = min_weight_leaf.ceil().to_usize().unwrap_or(0);
1331    min_samples_leaf.max(ceil_weight)
1332}
1333
1334/// Reject `±Inf` in `x` while ALLOWING `NaN` (missing values).
1335///
1336/// Mirrors scikit-learn's `_assert_all_finite(..., allow_nan=True)` path: the
1337/// DecisionTree base passes `force_all_finite=False` (`_classes.py:248-250`),
1338/// which only suppresses the NaN error — `has_inf` stays fatal and raises
1339/// `ValueError("Input X contains infinity or a value too large for dtype ...")`
1340/// (`sklearn/utils/validation.py:147-172`). `is_infinite()` is true for `±Inf`
1341/// and false for `NaN`/finite, so NaN passes through to the missing-value path
1342/// while `±Inf` is rejected. Applied at fit AND predict, classifier AND
1343/// regressor.
1344fn reject_infinite<F: Float>(x: &Array2<F>) -> Result<(), FerroError> {
1345    if x.iter().any(|v| v.is_infinite()) {
1346        return Err(FerroError::InvalidParameter {
1347            name: "X".into(),
1348            reason: "Input X contains infinity or a value too large for dtype.".into(),
1349        });
1350    }
1351    Ok(())
1352}
1353
1354/// Sort `idxs` ascending by feature `feat` of `x`, putting any NaN last.
1355///
1356/// Uses `Ordering::Equal` as the fallback for incomparable (NaN) pairs so the
1357/// sort is total without panicking — no `partial_cmp(..).unwrap()` in
1358/// production (R-CODE-2 / R-APG-1).
1359fn sort_indices_by_feature<F: Float>(idxs: &mut [usize], x: &Array2<F>, feat: usize) {
1360    use std::cmp::Ordering;
1361    idxs.sort_by(|&a, &b| {
1362        let va = x[[a, feat]];
1363        let vb = x[[b, feat]];
1364        // Force NaN (missing) values to sort LAST, mirroring sklearn's
1365        // partitioner which packs missing values into `samples[-n_missing:]`
1366        // (`_splitter.pyx:918-944`). `partial_cmp(..).unwrap_or(Equal)` alone
1367        // leaves NaN unordered (NaN comparisons are `None` ⇒ `Equal`), which
1368        // would scatter missing values through the sorted prefix.
1369        match (va.is_nan(), vb.is_nan()) {
1370            (true, true) => Ordering::Equal,
1371            (true, false) => Ordering::Greater,
1372            (false, true) => Ordering::Less,
1373            (false, false) => va.partial_cmp(&vb).unwrap_or(Ordering::Equal),
1374        }
1375    });
1376}
1377
1378/// Traverse the tree from root to leaf for a single sample, routing NaN
1379/// (missing) values to each split node's learned `missing_go_to_left`
1380/// direction.
1381///
1382/// `missing` is index-aligned with `nodes` (`true` ⇒ left). Finite values
1383/// compare `<= threshold` as usual; NaN routes to the learned direction
1384/// (`_tree.pyx:1015-1025`, `_apply_dense`). A `threshold = +∞` split (the
1385/// "all-missing-right" candidate) routes finite values left, NaN right.
1386fn traverse_tree<F: Float>(
1387    nodes: &[Node<F>],
1388    missing: &[bool],
1389    sample: &ndarray::ArrayView1<F>,
1390) -> usize {
1391    let mut idx = 0;
1392    loop {
1393        match &nodes[idx] {
1394            Node::Split {
1395                feature,
1396                threshold,
1397                left,
1398                right,
1399                ..
1400            } => {
1401                let v = sample[*feature];
1402                idx = if v.is_nan() {
1403                    if missing.get(idx).copied().unwrap_or(false) {
1404                        *left
1405                    } else {
1406                        *right
1407                    }
1408                } else if v <= *threshold {
1409                    *left
1410                } else {
1411                    *right
1412                };
1413            }
1414            Node::Leaf { .. } => return idx,
1415        }
1416    }
1417}
1418
1419/// Traverse a tree from root to leaf for a single sample (crate-public wrapper
1420/// for the forest ensembles, which do not carry missing-value routing).
1421///
1422/// Routes finite values via `<= threshold`; NaN goes right (the default
1423/// direction), matching the byte-identical pre-missing-value behaviour for the
1424/// random-splitter trees that never set a direction.
1425pub(crate) fn traverse<F: Float>(nodes: &[Node<F>], sample: &ndarray::ArrayView1<F>) -> usize {
1426    traverse_tree(nodes, &[], sample)
1427}
1428
1429/// Convert a `Float` value to `usize` (for class labels stored as floats).
1430fn float_to_usize<F: Float>(v: F) -> usize {
1431    v.to_f64().map_or(0, |f| f.round() as usize)
1432}
1433
1434/// Compute the Gini impurity for a set of class counts.
1435fn gini_impurity<F: Float>(class_counts: &[usize], total: usize) -> F {
1436    if total == 0 {
1437        return F::zero();
1438    }
1439    let total_f = F::from(total).unwrap();
1440    let mut impurity = F::one();
1441    for &count in class_counts {
1442        let p = F::from(count).unwrap() / total_f;
1443        impurity = impurity - p * p;
1444    }
1445    impurity
1446}
1447
1448/// Compute the Shannon entropy for a set of class counts.
1449fn entropy_impurity<F: Float>(class_counts: &[usize], total: usize) -> F {
1450    if total == 0 {
1451        return F::zero();
1452    }
1453    let total_f = F::from(total).unwrap();
1454    let mut ent = F::zero();
1455    for &count in class_counts {
1456        if count > 0 {
1457            let p = F::from(count).unwrap() / total_f;
1458            ent = ent - p * p.ln();
1459        }
1460    }
1461    ent
1462}
1463
1464/// Compute the mean of target values for the given indices.
1465fn mean_value<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1466    if indices.is_empty() {
1467        return F::zero();
1468    }
1469    let sum: F = indices.iter().map(|&i| y[i]).fold(F::zero(), |a, b| a + b);
1470    sum / F::from(indices.len()).unwrap()
1471}
1472
1473/// Compute the median of target values for the given indices.
1474///
1475/// Mirrors sklearn's `WeightedMedianCalculator.get_median` for the unweighted
1476/// case used by `MAE.node_value` (`_criterion.pyx:1419-1423`): for an
1477/// even-length sample the median is the average of the two middle (sorted)
1478/// values. Uses a NaN-last total order via `partial_cmp(..).unwrap_or(Equal)`
1479/// so it never panics (R-CODE-2 / R-APG-1).
1480fn median_value<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1481    let n = indices.len();
1482    if n == 0 {
1483        return F::zero();
1484    }
1485    let mut vals: Vec<F> = indices.iter().map(|&i| y[i]).collect();
1486    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1487    let mid = n / 2;
1488    if n % 2 == 1 {
1489        vals[mid]
1490    } else {
1491        (vals[mid - 1] + vals[mid]) / F::from(2.0).unwrap_or_else(F::one)
1492    }
1493}
1494
1495/// Compute the mean absolute error of the given indices around their median,
1496/// `(1/n)·Σ|y_i − median|` (`_criterion.pyx:1450-1472`, `MAE.node_impurity`).
1497fn mae_for_indices<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1498    let n = indices.len();
1499    if n == 0 {
1500        return F::zero();
1501    }
1502    let median = median_value(y, indices);
1503    let sum_abs: F = indices
1504        .iter()
1505        .map(|&i| (y[i] - median).abs())
1506        .fold(F::zero(), |a, b| a + b);
1507    sum_abs / F::from(n).unwrap_or_else(F::one)
1508}
1509
1510/// Compute the half-Poisson deviance impurity of the given indices,
1511/// `(1/n)·Σ y_i·ln(y_i/mean)` with `0·ln0 = 0` (`_criterion.pyx:1671-1708`,
1512/// `Poisson.poisson_loss`). Returns `+∞` when the node's target sum is
1513/// non-positive, mirroring sklearn's `y_sum <= EPSILON ⇒ return INFINITY`
1514/// guard (`_criterion.pyx:1691-1697`) so such splits are never selected.
1515fn poisson_deviance_for_indices<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1516    let n = indices.len();
1517    if n == 0 {
1518        return F::zero();
1519    }
1520    let n_f = F::from(n).unwrap_or_else(F::one);
1521    let sum: F = indices.iter().map(|&i| y[i]).fold(F::zero(), |a, b| a + b);
1522    if sum <= F::epsilon() {
1523        return F::infinity();
1524    }
1525    let mean = sum / n_f;
1526    let mut loss = F::zero();
1527    for &i in indices {
1528        let yi = y[i];
1529        // xlogy(y, y/mean): the `y == 0` term contributes 0 (`0·ln0 = 0`).
1530        if yi > F::zero() {
1531            loss = loss + yi * (yi / mean).ln();
1532        }
1533    }
1534    loss / n_f
1535}
1536
1537/// Compute the MSE for the given indices relative to a given mean.
1538fn mse_for_indices<F: Float>(y: &Array1<F>, indices: &[usize], mean: F) -> F {
1539    if indices.is_empty() {
1540        return F::zero();
1541    }
1542    let sum_sq: F = indices
1543        .iter()
1544        .map(|&i| {
1545            let diff = y[i] - mean;
1546            diff * diff
1547        })
1548        .fold(F::zero(), |a, b| a + b);
1549    sum_sq / F::from(indices.len()).unwrap()
1550}
1551
1552/// Compute the leaf prediction value for a regression node under `criterion`.
1553///
1554/// Mirrors `Criterion.node_value` (`_criterion.pyx`): MSE / FriedmanMSE /
1555/// Poisson predict the **mean** (`RegressionCriterion.node_value`,
1556/// `_criterion.pyx:1052`), while MAE / `absolute_error` predicts the **median**
1557/// (`MAE.node_value`, `_criterion.pyx:1419-1423`).
1558fn regression_leaf_value<F: Float>(
1559    y: &Array1<F>,
1560    indices: &[usize],
1561    criterion: RegressionCriterion,
1562) -> F {
1563    match criterion {
1564        RegressionCriterion::AbsoluteError => median_value(y, indices),
1565        RegressionCriterion::Mse
1566        | RegressionCriterion::FriedmanMse
1567        | RegressionCriterion::Poisson => mean_value(y, indices),
1568    }
1569}
1570
1571/// Compute the node impurity for a regression node under `criterion`
1572/// (`Criterion.node_impurity`).
1573fn regression_node_impurity<F: Float>(
1574    y: &Array1<F>,
1575    indices: &[usize],
1576    criterion: RegressionCriterion,
1577) -> F {
1578    match criterion {
1579        // FriedmanMSE shares MSE's node impurity (it only overrides the split
1580        // improvement); MSE is the variance around the mean (`_criterion.pyx:1094`).
1581        RegressionCriterion::Mse | RegressionCriterion::FriedmanMse => {
1582            mse_for_indices(y, indices, mean_value(y, indices))
1583        }
1584        RegressionCriterion::AbsoluteError => mae_for_indices(y, indices),
1585        RegressionCriterion::Poisson => poisson_deviance_for_indices(y, indices),
1586    }
1587}
1588
1589/// Compute impurity for a given classification criterion.
1590fn compute_impurity<F: Float>(
1591    class_counts: &[usize],
1592    total: usize,
1593    criterion: ClassificationCriterion,
1594) -> F {
1595    match criterion {
1596        ClassificationCriterion::Gini => gini_impurity(class_counts, total),
1597        // `log_loss` is an alias for `entropy` (`_classes.py:73`), so both use
1598        // the identical Shannon-entropy node-impurity formula.
1599        ClassificationCriterion::Entropy | ClassificationCriterion::LogLoss => {
1600            entropy_impurity(class_counts, total)
1601        }
1602    }
1603}
1604
1605// ---------------------------------------------------------------------------
1606// Weighted classification helpers (class_weight path)
1607// ---------------------------------------------------------------------------
1608
1609/// Weighted Gini impurity, `1 − Σ_c (W_c / W_total)²`, on per-class WEIGHTED
1610/// counts `W_c` (`Σ` of the sample weights in class `c`) and the total weighted
1611/// node mass `W_total`. The weighted analog of [`gini_impurity`]; with uniform
1612/// weights `W_c = count_c` and `W_total = N` it equals [`gini_impurity`]
1613/// exactly (`Gini.node_impurity`, `_criterion.pyx:695`, on weighted counts).
1614fn weighted_gini_impurity<F: Float>(weighted_counts: &[F], total: F) -> F {
1615    if total <= F::zero() {
1616        return F::zero();
1617    }
1618    let mut impurity = F::one();
1619    for &w in weighted_counts {
1620        let p = w / total;
1621        impurity = impurity - p * p;
1622    }
1623    impurity
1624}
1625
1626/// Weighted Shannon entropy, `−Σ_c (W_c / W_total)·ln(W_c / W_total)` (natural
1627/// log, `0·ln0 = 0`), on per-class WEIGHTED counts. The weighted analog of
1628/// [`entropy_impurity`]; equals it for uniform weights
1629/// (`Entropy.node_impurity`, `_criterion.pyx:655`).
1630fn weighted_entropy_impurity<F: Float>(weighted_counts: &[F], total: F) -> F {
1631    if total <= F::zero() {
1632        return F::zero();
1633    }
1634    let mut ent = F::zero();
1635    for &w in weighted_counts {
1636        if w > F::zero() {
1637            let p = w / total;
1638            ent = ent - p * p.ln();
1639        }
1640    }
1641    ent
1642}
1643
1644/// Weighted classification impurity dispatch, the weighted analog of
1645/// [`compute_impurity`] (kept as a SEPARATE function so [`compute_impurity`]'s
1646/// integer-count signature — shared with the forest/extra-tree builders — is
1647/// unchanged).
1648fn weighted_compute_impurity<F: Float>(
1649    weighted_counts: &[F],
1650    total: F,
1651    criterion: ClassificationCriterion,
1652) -> F {
1653    match criterion {
1654        ClassificationCriterion::Gini => weighted_gini_impurity(weighted_counts, total),
1655        ClassificationCriterion::Entropy | ClassificationCriterion::LogLoss => {
1656            weighted_entropy_impurity(weighted_counts, total)
1657        }
1658    }
1659}
1660
1661/// Accumulate per-class WEIGHTED counts `W_c = Σ_{i∈node, y_i=c} sample_weight[i]`
1662/// and the total weighted node mass `W_total = Σ_{i∈node} sample_weight[i]`
1663/// for the node's samples (`indices` are ORIGINAL sample indices).
1664fn weighted_class_counts<F: Float>(
1665    indices: &[usize],
1666    y: &[usize],
1667    n_classes: usize,
1668    sample_weight: &[F],
1669) -> (Vec<F>, F) {
1670    let mut counts = vec![F::zero(); n_classes];
1671    let mut total = F::zero();
1672    for &i in indices {
1673        let w = sample_weight[i];
1674        counts[y[i]] = counts[y[i]] + w;
1675        total = total + w;
1676    }
1677    (counts, total)
1678}
1679
1680/// Majority class (argmax of WEIGHTED counts, lowest index on ties — sklearn
1681/// `np.argmax`) and the normalized weighted class distribution `W_c / W_total`
1682/// for a classification node. The weighted analog of [`classification_node_value`]:
1683/// `predict_proba` = normalized weighted counts (`Tree.value` then row-normalized),
1684/// `predict` = weighted argmax.
1685fn weighted_classification_node_value<F: Float>(
1686    weighted_counts: &[F],
1687    total: F,
1688) -> (usize, Vec<F>) {
1689    let majority_class = {
1690        let mut best = 0usize;
1691        let mut best_w = F::neg_infinity();
1692        for (i, &w) in weighted_counts.iter().enumerate() {
1693            // Strictly-greater keeps the FIRST (lowest-index) maximum on ties,
1694            // matching `np.argmax`.
1695            if w > best_w {
1696                best_w = w;
1697                best = i;
1698            }
1699        }
1700        best
1701    };
1702    let denom = if total > F::zero() { total } else { F::one() };
1703    let distribution: Vec<F> = weighted_counts.iter().map(|&w| w / denom).collect();
1704    (majority_class, distribution)
1705}
1706
1707/// Create a WEIGHTED classification leaf node and return its index (the
1708/// weighted analog of [`make_classification_leaf`]). `predict_proba` stores the
1709/// normalized weighted distribution; the leaf value is the weighted argmax.
1710fn make_weighted_classification_leaf<F: Float>(
1711    nodes: &mut Vec<Node<F>>,
1712    meta: Option<&mut Vec<NodeMeta<F>>>,
1713    weighted_counts: &[F],
1714    total: F,
1715    n_samples: usize,
1716    criterion: ClassificationCriterion,
1717) -> usize {
1718    let (majority_class, distribution) =
1719        weighted_classification_node_value::<F>(weighted_counts, total);
1720    let value = F::from(majority_class).unwrap_or_else(F::zero);
1721
1722    let idx = nodes.len();
1723    nodes.push(Node::Leaf {
1724        value,
1725        class_distribution: Some(distribution.clone()),
1726        n_samples,
1727    });
1728    if let Some(meta) = meta {
1729        meta.push(NodeMeta {
1730            impurity: weighted_compute_impurity::<F>(weighted_counts, total, criterion),
1731            n_samples,
1732            value,
1733            distribution: Some(distribution),
1734            // Leaf node: no missing-value routing.
1735            missing_go_to_left: false,
1736        });
1737    }
1738    idx
1739}
1740
1741/// Emit a classification leaf for the node's `indices`, dispatching to the
1742/// WEIGHTED leaf maker when `data.sample_weight` is set and the unweighted maker
1743/// otherwise (byte-identical to the prior path on the unweighted branch).
1744/// `class_counts` is the already-accumulated UNWEIGHTED count vector (reused for
1745/// the unweighted leaf); the weighted leaf re-accumulates weighted counts.
1746fn emit_classification_leaf<F: Float>(
1747    data: &ClassificationData<'_, F>,
1748    indices: &[usize],
1749    nodes: &mut Vec<Node<F>>,
1750    meta: Option<&mut Vec<NodeMeta<F>>>,
1751    class_counts: &[usize],
1752    n_samples: usize,
1753) -> usize {
1754    if let Some(sw) = data.sample_weight {
1755        let (wc, total) = weighted_class_counts(indices, data.y, data.n_classes, sw);
1756        make_weighted_classification_leaf(nodes, meta, &wc, total, n_samples, data.criterion)
1757    } else {
1758        make_classification_leaf(
1759            nodes,
1760            meta,
1761            class_counts,
1762            data.n_classes,
1763            n_samples,
1764            data.criterion,
1765        )
1766    }
1767}
1768
1769/// Compute the majority class (argmax of `class_counts`) and the normalized
1770/// class distribution for a classification node.
1771///
1772/// On a count tie, sklearn's `np.argmax` returns the LOWEST index, so the
1773/// argmax updates only on a strictly greater count (keeping the first maximum)
1774/// rather than `max_by_key`, which would return the last.
1775fn classification_node_value<F: Float>(
1776    class_counts: &[usize],
1777    n_classes: usize,
1778    n_samples: usize,
1779) -> (usize, Vec<F>) {
1780    let majority_class = {
1781        let mut best = 0usize;
1782        let mut best_count = 0usize;
1783        for (i, &count) in class_counts.iter().enumerate() {
1784            if count > best_count {
1785                best_count = count;
1786                best = i;
1787            }
1788        }
1789        best
1790    };
1791
1792    let total_f = if n_samples > 0 {
1793        F::from(n_samples).unwrap_or_else(F::one)
1794    } else {
1795        F::one()
1796    };
1797    let distribution: Vec<F> = (0..n_classes)
1798        .map(|c| F::from(class_counts[c]).unwrap_or_else(F::zero) / total_f)
1799        .collect();
1800    (majority_class, distribution)
1801}
1802
1803/// Create a classification leaf node and return its index.
1804///
1805/// When `meta` is `Some` (the estimator builder with `ccp_alpha > 0`), records
1806/// the node's own impurity / value / distribution / sample count for later
1807/// minimal cost-complexity pruning.
1808fn make_classification_leaf<F: Float>(
1809    nodes: &mut Vec<Node<F>>,
1810    meta: Option<&mut Vec<NodeMeta<F>>>,
1811    class_counts: &[usize],
1812    n_classes: usize,
1813    n_samples: usize,
1814    criterion: ClassificationCriterion,
1815) -> usize {
1816    let (majority_class, distribution) =
1817        classification_node_value::<F>(class_counts, n_classes, n_samples);
1818    let value = F::from(majority_class).unwrap_or_else(F::zero);
1819
1820    let idx = nodes.len();
1821    nodes.push(Node::Leaf {
1822        value,
1823        class_distribution: Some(distribution.clone()),
1824        n_samples,
1825    });
1826    if let Some(meta) = meta {
1827        meta.push(NodeMeta {
1828            impurity: compute_impurity::<F>(class_counts, n_samples, criterion),
1829            n_samples,
1830            value,
1831            distribution: Some(distribution),
1832            // Leaf node: no missing-value routing.
1833            missing_go_to_left: false,
1834        });
1835    }
1836    idx
1837}
1838
1839/// Build a classification tree recursively.
1840///
1841/// Returns the index of the node that was created at the root of this subtree.
1842#[allow(
1843    clippy::too_many_arguments,
1844    reason = "recursive builder threads data/nodes/prune-meta/params/gate/rng; bundling would obscure the recursion"
1845)]
1846fn build_classification_tree<F: Float>(
1847    data: &ClassificationData<'_, F>,
1848    indices: &[usize],
1849    nodes: &mut Vec<Node<F>>,
1850    mut meta: Option<&mut Vec<NodeMeta<F>>>,
1851    depth: usize,
1852    params: &TreeParams,
1853    gate: &ImpurityGate<F>,
1854    mut rng: Option<&mut StdRng>,
1855) -> usize {
1856    let n = indices.len();
1857
1858    let mut class_counts = vec![0usize; data.n_classes];
1859    for &i in indices {
1860        class_counts[data.y[i]] += 1;
1861    }
1862
1863    // The pure-node / min_samples / max_depth stops use UNWEIGHTED sample counts
1864    // (sklearn `min_samples_split`/`min_samples_leaf` count raw samples even when
1865    // `sample_weight` is set, `_splitter.pyx:451`); the pure-node check is on the
1866    // number of non-empty classes, which is weight-invariant.
1867    let should_stop = n < params.min_samples_split
1868        || params.max_depth.is_some_and(|d| depth >= d)
1869        || class_counts.iter().filter(|&&c| c > 0).count() <= 1;
1870
1871    if should_stop {
1872        return emit_classification_leaf(
1873            data,
1874            indices,
1875            nodes,
1876            meta.as_deref_mut(),
1877            &class_counts,
1878            n,
1879        );
1880    }
1881
1882    // Reborrow the rng for the split-finder; recursive children get fresh
1883    // reborrows via `rng.as_deref_mut()` below.
1884    let best =
1885        find_best_classification_split(data, indices, params.min_samples_leaf, rng.as_deref_mut());
1886
1887    // `min_impurity_decrease` gate (`_tree.pyx:284`): reject the split (make a
1888    // leaf) when its tree-normalized improvement is below the threshold. The
1889    // finder returns `best_impurity_decrease = improvement_inner · n_node`
1890    // where `improvement_inner = parent − Σ(n_child/n_node)·imp_child`; the
1891    // tree-normalized improvement of `_criterion.pyx:188` is then
1892    // `(n_node/N)·improvement_inner = best_impurity_decrease / N`.
1893    let gated = best.filter(|&(_, _, best_impurity_decrease, _)| {
1894        let n_total_f = F::from(gate.n_total).unwrap_or_else(F::one);
1895        let improvement = best_impurity_decrease / n_total_f;
1896        !gate.rejects(improvement)
1897    });
1898
1899    if let Some((best_feature, best_threshold, best_impurity_decrease, missing_go_to_left)) = gated
1900    {
1901        let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
1902            indices,
1903            data.x,
1904            best_feature,
1905            best_threshold,
1906            missing_go_to_left,
1907        );
1908
1909        let node_idx = nodes.len();
1910        nodes.push(Node::Leaf {
1911            value: F::zero(),
1912            class_distribution: None,
1913            n_samples: 0,
1914        }); // placeholder
1915        // Keep `meta` index-aligned with `nodes`: push a placeholder for this
1916        // internal node now, overwritten below with its OWN impurity/value/
1917        // distribution (the leaf it collapses to under `ccp_alpha` pruning).
1918        if let Some(meta) = meta.as_deref_mut() {
1919            meta.push(NodeMeta {
1920                impurity: F::zero(),
1921                n_samples: 0,
1922                value: F::zero(),
1923                distribution: None,
1924                missing_go_to_left: false,
1925            });
1926        }
1927
1928        let left_idx = build_classification_tree(
1929            data,
1930            &left_indices,
1931            nodes,
1932            meta.as_deref_mut(),
1933            depth + 1,
1934            params,
1935            gate,
1936            rng.as_deref_mut(),
1937        );
1938        let right_idx = build_classification_tree(
1939            data,
1940            &right_indices,
1941            nodes,
1942            meta.as_deref_mut(),
1943            depth + 1,
1944            params,
1945            gate,
1946            rng,
1947        );
1948
1949        nodes[node_idx] = Node::Split {
1950            feature: best_feature,
1951            threshold: best_threshold,
1952            left: left_idx,
1953            right: right_idx,
1954            impurity_decrease: best_impurity_decrease,
1955            n_samples: n,
1956        };
1957
1958        if let Some(meta) = meta {
1959            let (majority_class, distribution, impurity) = if let Some(sw) = data.sample_weight {
1960                let (wc, total) = weighted_class_counts(indices, data.y, data.n_classes, sw);
1961                let (mc, dist) = weighted_classification_node_value::<F>(&wc, total);
1962                (
1963                    mc,
1964                    dist,
1965                    weighted_compute_impurity::<F>(&wc, total, data.criterion),
1966                )
1967            } else {
1968                let (mc, dist) = classification_node_value::<F>(&class_counts, data.n_classes, n);
1969                (
1970                    mc,
1971                    dist,
1972                    compute_impurity::<F>(&class_counts, n, data.criterion),
1973                )
1974            };
1975            meta[node_idx] = NodeMeta {
1976                impurity,
1977                n_samples: n,
1978                value: F::from(majority_class).unwrap_or_else(F::zero),
1979                distribution: Some(distribution),
1980                missing_go_to_left,
1981            };
1982        }
1983
1984        node_idx
1985    } else {
1986        emit_classification_leaf(data, indices, nodes, meta, &class_counts, n)
1987    }
1988}
1989
1990/// Find the best split for a classification node.
1991///
1992/// Returns `(feature_index, threshold, weighted_impurity_decrease)` or `None`.
1993///
1994/// When `data.max_features_per_split` is set, `rng` must be `Some` and a fresh
1995/// random subset of that many features is drawn for this single split (the
1996/// per-split feature sampling used by Breiman 2001 RandomForest and
1997/// scikit-learn). When `data.feature_indices` is set, the fixed per-tree
1998/// subset is used instead. Otherwise all features are considered.
1999fn find_best_classification_split<F: Float>(
2000    data: &ClassificationData<'_, F>,
2001    indices: &[usize],
2002    min_samples_leaf: usize,
2003    rng: Option<&mut StdRng>,
2004) -> Option<(usize, F, F, bool)> {
2005    let n = indices.len();
2006    let n_f = F::from(n).unwrap_or_else(F::one);
2007    let n_features = data.x.ncols();
2008
2009    let mut parent_counts = vec![0usize; data.n_classes];
2010    for &i in indices {
2011        parent_counts[data.y[i]] += 1;
2012    }
2013
2014    // Weighted parent counts + total weighted mass on the `class_weight` path
2015    // (the `compute_sample_weight`-folded weights, `_classes.py:310-367`). On the
2016    // unweighted path `parent_weighted` stays `None` and the integer-count
2017    // impurity below runs byte-identical.
2018    let (parent_impurity, weighted_parent) = match data.sample_weight {
2019        Some(sw) => {
2020            let (wc, total) = weighted_class_counts(indices, data.y, data.n_classes, sw);
2021            (
2022                weighted_compute_impurity::<F>(&wc, total, data.criterion),
2023                Some((wc, total, sw)),
2024            )
2025        }
2026        None => (
2027            compute_impurity::<F>(&parent_counts, n, data.criterion),
2028            None,
2029        ),
2030    };
2031
2032    let mut best_score = F::neg_infinity();
2033    let mut best_feature = 0;
2034    let mut best_threshold = F::zero();
2035    let mut best_missing_left = false;
2036    // The weighted node mass `W_node` (= Σ sample_weight over the node), used to
2037    // rescale the returned `best_impurity_decrease = improvement_inner · W_node`
2038    // on the weighted path so it mirrors sklearn's `improvement·N` convention.
2039    let mut best_weighted_n = n_f;
2040
2041    // Build the candidate feature list for this split.
2042    //
2043    // Priority:
2044    //   1. `max_features_per_split` — sample fresh subset using rng (Breiman RF).
2045    //   2. `feature_indices`        — fixed per-tree subset (Bagging).
2046    //   3. otherwise                — all features (plain DT).
2047    let candidate_features: Vec<usize> = match (data.max_features_per_split, rng) {
2048        (Some(k), Some(rng)) => {
2049            let k = k.min(n_features).max(1);
2050            rand_sample_indices(rng, n_features, k).into_vec()
2051        }
2052        _ => match data.feature_indices {
2053            Some(feat) => feat.to_vec(),
2054            None => (0..n_features).collect(),
2055        },
2056    };
2057
2058    let threshold_band = feature_threshold::<F>();
2059
2060    for feat in candidate_features {
2061        let mut sorted_indices: Vec<usize> = indices.to_vec();
2062        // NaN sorts last (`sort_indices_by_feature`), mirroring sklearn's
2063        // partitioner which packs missing values into `samples[-n_missing:]`
2064        // (`_splitter.pyx:918-944`).
2065        sort_indices_by_feature(&mut sorted_indices, data.x, feat);
2066        let n_missing = sorted_indices
2067            .iter()
2068            .filter(|&&i| data.x[[i, feat]].is_nan())
2069            .count();
2070        let n_nonmissing = n - n_missing;
2071        // All values missing ⇒ no non-missing split point (`_splitter.pyx:402`,
2072        // `end_non_missing == start`).
2073        if n_nonmissing == 0 {
2074            continue;
2075        }
2076        let has_missing = n_missing > 0;
2077
2078        // Constant-feature band over the NON-missing samples only
2079        // (`_splitter.pyx:405`, `feature_values[end_non_missing-1] <=
2080        // feature_values[start] + FEATURE_THRESHOLD`).
2081        let feat_min = data.x[[sorted_indices[0], feat]];
2082        let feat_max = data.x[[sorted_indices[n_nonmissing - 1], feat]];
2083        if feat_max <= feat_min + threshold_band {
2084            continue;
2085        }
2086
2087        // The missing block's per-class weighted counts + total weighted mass
2088        // (moved together to one child), and its integer class counts.
2089        let (missing_w_counts, missing_w) = match weighted_parent.as_ref() {
2090            Some((_, _, sw)) => {
2091                let mut mc = vec![F::zero(); data.n_classes];
2092                let mut mw = F::zero();
2093                for &i in &sorted_indices[n_nonmissing..] {
2094                    mc[data.y[i]] = mc[data.y[i]] + sw[i];
2095                    mw = mw + sw[i];
2096                }
2097                (mc, mw)
2098            }
2099            None => (Vec::new(), F::zero()),
2100        };
2101        let mut missing_counts = vec![0usize; data.n_classes];
2102        for &i in &sorted_indices[n_nonmissing..] {
2103            missing_counts[data.y[i]] += 1;
2104        }
2105
2106        // NON-missing parent counts: the running right-child starts here (the
2107        // missing block is folded into the chosen child separately, so it must
2108        // NOT already be in the running counts). `parent_counts` includes the
2109        // missing samples, so subtract them.
2110        let nonmissing_parent_counts: Vec<usize> = parent_counts
2111            .iter()
2112            .zip(missing_counts.iter())
2113            .map(|(p, m)| p - m)
2114            .collect();
2115        let nonmissing_weighted_parent: Vec<F> = match weighted_parent.as_ref() {
2116            Some((wc, _, _)) => wc
2117                .iter()
2118                .zip(missing_w_counts.iter())
2119                .map(|(p, m)| *p - *m)
2120                .collect(),
2121            None => Vec::new(),
2122        };
2123
2124        // sklearn searches once with no missing, twice otherwise: pass 0 sends
2125        // missing → right, pass 1 sends missing → left (`_splitter.pyx:428-431`).
2126        // With no missing, only pass 0 runs and `missing_go_to_left` stays
2127        // false (the byte-identical original path).
2128        let n_searches = if has_missing { 2 } else { 1 };
2129
2130        for search in 0..n_searches {
2131            let missing_to_left = search == 1;
2132
2133            // Running NON-missing left counts; right starts at the NON-missing
2134            // parent counts and mass is moved left as the scan advances over the
2135            // sorted non-missing prefix. The missing block is folded into the
2136            // chosen child separately (`combine_missing_*`).
2137            let mut left_counts = vec![0usize; data.n_classes];
2138            let mut right_counts = nonmissing_parent_counts.clone();
2139            let mut left_w_counts = vec![F::zero(); data.n_classes];
2140            let mut right_w_counts = nonmissing_weighted_parent.clone();
2141            let mut left_nm = 0usize;
2142            let mut left_w_nm = F::zero();
2143
2144            for split_pos in 0..n_nonmissing - 1 {
2145                let idx = sorted_indices[split_pos];
2146                let cls = data.y[idx];
2147                left_counts[cls] += 1;
2148                right_counts[cls] -= 1;
2149                left_nm += 1;
2150                if let Some((_, _, sw)) = weighted_parent.as_ref() {
2151                    let w = sw[idx];
2152                    left_w_counts[cls] = left_w_counts[cls] + w;
2153                    right_w_counts[cls] = right_w_counts[cls] - w;
2154                    left_w_nm = left_w_nm + w;
2155                }
2156
2157                // Adjacent sorted (non-missing) values must differ by more than
2158                // FEATURE_THRESHOLD (sklearn's `next_p` skip, `_splitter.pyx`).
2159                let next_idx = sorted_indices[split_pos + 1];
2160                if data.x[[next_idx, feat]] <= data.x[[idx, feat]] + threshold_band {
2161                    continue;
2162                }
2163
2164                // Fold the missing block into the chosen child's sample counts.
2165                let (left_n, right_n) = if missing_to_left {
2166                    (left_nm + n_missing, n_nonmissing - left_nm)
2167                } else {
2168                    (left_nm, n_nonmissing - left_nm + n_missing)
2169                };
2170
2171                // `min_samples_leaf` counts RAW samples even under sample_weight
2172                // (`_splitter.pyx:451`).
2173                if left_n < min_samples_leaf || right_n < min_samples_leaf {
2174                    continue;
2175                }
2176
2177                let (impurity_decrease, weighted_n) = if let Some((_, total_w, _)) =
2178                    weighted_parent.as_ref()
2179                {
2180                    // The NON-missing right mass = (total − missing) − left_nm;
2181                    // `combine_missing_weighted` then folds the missing block in.
2182                    let right_w_nm = *total_w - missing_w - left_w_nm;
2183                    let (lc, rc, left_w, right_w) = combine_missing_weighted(
2184                        &left_w_counts,
2185                        &right_w_counts,
2186                        left_w_nm,
2187                        right_w_nm,
2188                        &missing_w_counts,
2189                        missing_w,
2190                        missing_to_left,
2191                    );
2192                    // Weighted `min_weight_fraction_leaf` child gate
2193                    // (`_splitter.pyx:470`). Default `0.0` never rejects.
2194                    if left_w < data.min_weight_leaf || right_w < data.min_weight_leaf {
2195                        continue;
2196                    }
2197                    let left_impurity = weighted_compute_impurity::<F>(&lc, left_w, data.criterion);
2198                    let right_impurity =
2199                        weighted_compute_impurity::<F>(&rc, right_w, data.criterion);
2200                    let denom = if *total_w > F::zero() {
2201                        *total_w
2202                    } else {
2203                        F::one()
2204                    };
2205                    let weighted_child =
2206                        (left_w * left_impurity + right_w * right_impurity) / denom;
2207                    (parent_impurity - weighted_child, *total_w)
2208                } else {
2209                    let (lc, rc) = combine_missing_counts(
2210                        &left_counts,
2211                        &right_counts,
2212                        &missing_counts,
2213                        missing_to_left,
2214                    );
2215                    let left_impurity = compute_impurity::<F>(&lc, left_n, data.criterion);
2216                    let right_impurity = compute_impurity::<F>(&rc, right_n, data.criterion);
2217                    let left_weight = F::from(left_n).unwrap_or_else(F::one) / n_f;
2218                    let right_weight = F::from(right_n).unwrap_or_else(F::one) / n_f;
2219                    let weighted_child_impurity =
2220                        left_weight * left_impurity + right_weight * right_impurity;
2221                    (parent_impurity - weighted_child_impurity, n_f)
2222                };
2223
2224                if impurity_decrease > best_score {
2225                    best_score = impurity_decrease;
2226                    best_feature = feat;
2227                    best_weighted_n = weighted_n;
2228                    best_missing_left = if has_missing { missing_to_left } else { false };
2229                    let two = F::from(2.0).unwrap_or_else(F::one);
2230                    best_threshold = (data.x[[idx, feat]] + data.x[[next_idx, feat]]) / two;
2231                }
2232            }
2233        }
2234
2235        // The extra candidate: ALL non-missing left, ALL missing right
2236        // (threshold = +∞, `missing_go_to_left = 0`), evaluated only when there
2237        // ARE missing values (`_splitter.pyx:498-519`).
2238        if has_missing {
2239            let left_n = n_nonmissing;
2240            let right_n = n_missing;
2241            if left_n >= min_samples_leaf && right_n >= min_samples_leaf {
2242                let candidate = if let Some((pwc, total_w, _)) = weighted_parent.as_ref() {
2243                    let left_w = *total_w - missing_w;
2244                    let right_w = missing_w;
2245                    if left_w >= data.min_weight_leaf && right_w >= data.min_weight_leaf {
2246                        let mut lc = pwc.clone();
2247                        for (c, m) in lc.iter_mut().zip(missing_w_counts.iter()) {
2248                            *c = *c - *m;
2249                        }
2250                        let left_impurity =
2251                            weighted_compute_impurity::<F>(&lc, left_w, data.criterion);
2252                        let right_impurity = weighted_compute_impurity::<F>(
2253                            &missing_w_counts,
2254                            right_w,
2255                            data.criterion,
2256                        );
2257                        let denom = if *total_w > F::zero() {
2258                            *total_w
2259                        } else {
2260                            F::one()
2261                        };
2262                        let weighted_child =
2263                            (left_w * left_impurity + right_w * right_impurity) / denom;
2264                        Some((parent_impurity - weighted_child, *total_w))
2265                    } else {
2266                        None
2267                    }
2268                } else {
2269                    let mut lc = parent_counts.clone();
2270                    for (c, m) in lc.iter_mut().zip(missing_counts.iter()) {
2271                        *c -= *m;
2272                    }
2273                    let left_impurity = compute_impurity::<F>(&lc, left_n, data.criterion);
2274                    let right_impurity =
2275                        compute_impurity::<F>(&missing_counts, right_n, data.criterion);
2276                    let left_weight = F::from(left_n).unwrap_or_else(F::one) / n_f;
2277                    let right_weight = F::from(right_n).unwrap_or_else(F::one) / n_f;
2278                    let weighted_child_impurity =
2279                        left_weight * left_impurity + right_weight * right_impurity;
2280                    Some((parent_impurity - weighted_child_impurity, n_f))
2281                };
2282
2283                if let Some((decrease, weighted_n)) = candidate
2284                    && decrease > best_score
2285                {
2286                    best_score = decrease;
2287                    best_feature = feat;
2288                    best_weighted_n = weighted_n;
2289                    best_missing_left = false;
2290                    best_threshold = F::infinity();
2291                }
2292            }
2293        }
2294    }
2295
2296    // Return the best split with a valid child-size split point, even when its
2297    // improvement is exactly `0` (the impurity decrease is `>= 0` for
2298    // gini/entropy). sklearn accepts the best split regardless of sign and lets
2299    // the `min_impurity_decrease` gate (`improvement + EPSILON <
2300    // min_impurity_decrease`, `_tree.pyx:284`) reject it in the build loop; the
2301    // default `0.0` threshold accepts zero-improvement splits (e.g. the
2302    // `min_weight_fraction_leaf` fallback split). `best_score` stays `-inf`
2303    // when no valid split point exists ⇒ `None`.
2304    if best_score >= F::zero() {
2305        // Return `improvement_inner · weighted_n_node` (= `improvement_inner · n`
2306        // on the unweighted path, where `best_weighted_n == n_f`). The build's
2307        // `min_impurity_decrease` gate and `compute_feature_importances` both
2308        // apply the same global denominator, so this scaling keeps the
2309        // unweighted path byte-identical and yields sklearn-consistent
2310        // (post-normalization) weighted importances.
2311        Some((
2312            best_feature,
2313            best_threshold,
2314            best_score * best_weighted_n,
2315            best_missing_left,
2316        ))
2317    } else {
2318        None
2319    }
2320}
2321
2322/// Fold a missing block's integer class counts into the left or right child's
2323/// running counts (the non-missing running split), returning `(left, right)`.
2324fn combine_missing_counts(
2325    left_counts: &[usize],
2326    right_counts: &[usize],
2327    missing_counts: &[usize],
2328    missing_to_left: bool,
2329) -> (Vec<usize>, Vec<usize>) {
2330    let mut lc = left_counts.to_vec();
2331    let mut rc = right_counts.to_vec();
2332    if missing_to_left {
2333        for (c, m) in lc.iter_mut().zip(missing_counts.iter()) {
2334            *c += *m;
2335        }
2336    } else {
2337        for (c, m) in rc.iter_mut().zip(missing_counts.iter()) {
2338            *c += *m;
2339        }
2340    }
2341    (lc, rc)
2342}
2343
2344/// Fold a missing block's weighted class counts/mass into the left or right
2345/// child's running weighted counts/mass, returning `(lc, rc, left_w, right_w)`.
2346#[allow(
2347    clippy::too_many_arguments,
2348    reason = "threads both children's weighted counts + masses plus the missing block"
2349)]
2350fn combine_missing_weighted<F: Float>(
2351    left_w_counts: &[F],
2352    right_w_counts: &[F],
2353    left_w: F,
2354    right_w: F,
2355    missing_w_counts: &[F],
2356    missing_w: F,
2357    missing_to_left: bool,
2358) -> (Vec<F>, Vec<F>, F, F) {
2359    let mut lc = left_w_counts.to_vec();
2360    let mut rc = right_w_counts.to_vec();
2361    if missing_to_left {
2362        for (c, m) in lc.iter_mut().zip(missing_w_counts.iter()) {
2363            *c = *c + *m;
2364        }
2365        (lc, rc, left_w + missing_w, right_w)
2366    } else {
2367        for (c, m) in rc.iter_mut().zip(missing_w_counts.iter()) {
2368            *c = *c + *m;
2369        }
2370        (lc, rc, left_w, right_w + missing_w)
2371    }
2372}
2373
2374/// Partition `indices` into `(left, right)` for a split on `feature` at
2375/// `threshold`, routing NaN (missing) samples to the side given by
2376/// `missing_go_to_left` rather than the `<= threshold` comparison.
2377///
2378/// Mirrors sklearn's predict/fit routing (`_tree.pyx:1015-1025`,
2379/// `_apply_dense`): `isnan(x) ⇒ left if missing_go_to_left else right`,
2380/// otherwise `x <= threshold ⇒ left`. The current `<=`-only partition sent NaN
2381/// right always (NaN `<= t` is `false`), which on a NaN-derived `(n,0)` split
2382/// drove the unbounded recursion (#2277). A `threshold = +∞` split (the
2383/// "all non-missing left, all missing right" candidate) routes every finite
2384/// value left and every NaN right, exactly as sklearn's `INFINITY` threshold.
2385fn partition_with_missing<F: Float>(
2386    indices: &[usize],
2387    x: &Array2<F>,
2388    feature: usize,
2389    threshold: F,
2390    missing_go_to_left: bool,
2391) -> (Vec<usize>, Vec<usize>) {
2392    indices.iter().partition(|&&i| {
2393        let v = x[[i, feature]];
2394        if v.is_nan() {
2395            missing_go_to_left
2396        } else {
2397            v <= threshold
2398        }
2399    })
2400}
2401
2402/// Push a regression leaf node (and, when `meta` is `Some`, its pruning
2403/// metadata) at the end of `nodes`, returning its index.
2404fn push_regression_leaf<F: Float>(
2405    nodes: &mut Vec<Node<F>>,
2406    meta: Option<&mut Vec<NodeMeta<F>>>,
2407    value: F,
2408    impurity: F,
2409    n_samples: usize,
2410) -> usize {
2411    let idx = nodes.len();
2412    nodes.push(Node::Leaf {
2413        value,
2414        class_distribution: None,
2415        n_samples,
2416    });
2417    if let Some(meta) = meta {
2418        meta.push(NodeMeta {
2419            impurity,
2420            n_samples,
2421            value,
2422            distribution: None,
2423            // Leaf node: no missing-value routing.
2424            missing_go_to_left: false,
2425        });
2426    }
2427    idx
2428}
2429
2430/// Build a regression tree recursively.
2431#[allow(
2432    clippy::too_many_arguments,
2433    reason = "recursive builder threads data/nodes/prune-meta/params/gate/rng; bundling would obscure the recursion"
2434)]
2435fn build_regression_tree<F: Float>(
2436    data: &RegressionData<'_, F>,
2437    indices: &[usize],
2438    nodes: &mut Vec<Node<F>>,
2439    mut meta: Option<&mut Vec<NodeMeta<F>>>,
2440    depth: usize,
2441    params: &TreeParams,
2442    gate: &ImpurityGate<F>,
2443    mut rng: Option<&mut StdRng>,
2444) -> usize {
2445    let n = indices.len();
2446    // Leaf prediction depends on the criterion: median for absolute_error,
2447    // mean for squared_error / friedman_mse / poisson (`Criterion.node_value`).
2448    let leaf_value = regression_leaf_value(data.y, indices, data.criterion);
2449
2450    let should_stop = n < params.min_samples_split || params.max_depth.is_some_and(|d| depth >= d);
2451
2452    if should_stop {
2453        // The leaf's own impurity (recorded only when pruning) — sklearn keeps
2454        // each node's stored impurity for `R(t)`.
2455        let imp = meta.as_deref().map_or(F::zero(), |_| {
2456            regression_node_impurity(data.y, indices, data.criterion)
2457        });
2458        return push_regression_leaf(nodes, meta, leaf_value, imp, n);
2459    }
2460
2461    let parent_impurity = regression_node_impurity(data.y, indices, data.criterion);
2462    if parent_impurity <= F::epsilon() {
2463        return push_regression_leaf(nodes, meta, leaf_value, parent_impurity, n);
2464    }
2465
2466    let best =
2467        find_best_regression_split(data, indices, params.min_samples_leaf, rng.as_deref_mut());
2468
2469    // `min_impurity_decrease` gate (`_tree.pyx:284`). The finder returns
2470    // `best_impurity_decrease = score · n_node`. For MSE / absolute_error /
2471    // poisson, `score = parent − Σ(n_child/n_node)·imp_child`, so the
2472    // tree-normalized improvement `(n_node/N)·score` equals
2473    // `best_impurity_decrease / N`. For friedman_mse the finder's `score` IS
2474    // `FriedmanMSE.impurity_improvement = diff²/(n_L·n_R·n_node)`
2475    // (`_criterion.pyx:1573`) — already tree-normalized, with NO extra `1/N`
2476    // factor — so the improvement is `best_impurity_decrease / n_node = score`.
2477    let gated = best.filter(|&(_, _, best_impurity_decrease, _)| {
2478        let denom = match data.criterion {
2479            RegressionCriterion::FriedmanMse => n,
2480            _ => gate.n_total,
2481        };
2482        let denom_f = F::from(denom).unwrap_or_else(F::one);
2483        let improvement = best_impurity_decrease / denom_f;
2484        !gate.rejects(improvement)
2485    });
2486
2487    if let Some((best_feature, best_threshold, best_impurity_decrease, missing_go_to_left)) = gated
2488    {
2489        let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
2490            indices,
2491            data.x,
2492            best_feature,
2493            best_threshold,
2494            missing_go_to_left,
2495        );
2496
2497        let node_idx = nodes.len();
2498        nodes.push(Node::Leaf {
2499            value: F::zero(),
2500            class_distribution: None,
2501            n_samples: 0,
2502        }); // placeholder
2503        // Keep `meta` index-aligned with `nodes`; overwrite below with this
2504        // internal node's OWN impurity/value (the leaf it collapses to).
2505        if let Some(meta) = meta.as_deref_mut() {
2506            meta.push(NodeMeta {
2507                impurity: F::zero(),
2508                n_samples: 0,
2509                value: F::zero(),
2510                distribution: None,
2511                missing_go_to_left: false,
2512            });
2513        }
2514
2515        let left_idx = build_regression_tree(
2516            data,
2517            &left_indices,
2518            nodes,
2519            meta.as_deref_mut(),
2520            depth + 1,
2521            params,
2522            gate,
2523            rng.as_deref_mut(),
2524        );
2525        let right_idx = build_regression_tree(
2526            data,
2527            &right_indices,
2528            nodes,
2529            meta.as_deref_mut(),
2530            depth + 1,
2531            params,
2532            gate,
2533            rng,
2534        );
2535
2536        nodes[node_idx] = Node::Split {
2537            feature: best_feature,
2538            threshold: best_threshold,
2539            left: left_idx,
2540            right: right_idx,
2541            impurity_decrease: best_impurity_decrease,
2542            n_samples: n,
2543        };
2544
2545        if let Some(meta) = meta {
2546            meta[node_idx] = NodeMeta {
2547                impurity: parent_impurity,
2548                n_samples: n,
2549                value: leaf_value,
2550                distribution: None,
2551                missing_go_to_left,
2552            };
2553        }
2554
2555        node_idx
2556    } else {
2557        push_regression_leaf(nodes, meta, leaf_value, parent_impurity, n)
2558    }
2559}
2560
2561/// Find the best split for a regression node using MSE reduction.
2562///
2563/// Returns `(feature_index, threshold, weighted_mse_decrease)` or `None`.
2564///
2565/// See [`find_best_classification_split`] for the candidate-feature selection
2566/// rules (per-split sampling vs fixed subset vs all features).
2567fn find_best_regression_split<F: Float>(
2568    data: &RegressionData<'_, F>,
2569    indices: &[usize],
2570    min_samples_leaf: usize,
2571    rng: Option<&mut StdRng>,
2572) -> Option<(usize, F, F, bool)> {
2573    let n = indices.len();
2574    let n_f = F::from(n).unwrap_or_else(F::one);
2575    let n_features = data.x.ncols();
2576
2577    let parent_sum: F = indices
2578        .iter()
2579        .map(|&i| data.y[i])
2580        .fold(F::zero(), |a, b| a + b);
2581    let parent_sum_sq: F = indices
2582        .iter()
2583        .map(|&i| data.y[i] * data.y[i])
2584        .fold(F::zero(), |a, b| a + b);
2585    let parent_mse = parent_sum_sq / n_f - (parent_sum / n_f) * (parent_sum / n_f);
2586    // Parent impurity for the median-based (absolute_error) and Poisson criteria;
2587    // unused (and cheap to leave) for the variance-based MSE/FriedmanMSE paths,
2588    // which score off `parent_mse` / the Friedman proxy instead.
2589    let parent_impurity = regression_node_impurity(data.y, indices, data.criterion);
2590
2591    let mut best_score = F::neg_infinity();
2592    let mut best_feature = 0;
2593    let mut best_threshold = F::zero();
2594    let mut best_missing_left = false;
2595
2596    let candidate_features: Vec<usize> = match (data.max_features_per_split, rng) {
2597        (Some(k), Some(rng)) => {
2598            let k = k.min(n_features).max(1);
2599            rand_sample_indices(rng, n_features, k).into_vec()
2600        }
2601        _ => match data.feature_indices {
2602            Some(feat) => feat.to_vec(),
2603            None => (0..n_features).collect(),
2604        },
2605    };
2606
2607    let threshold_band = feature_threshold::<F>();
2608
2609    for feat in candidate_features {
2610        let mut sorted_indices: Vec<usize> = indices.to_vec();
2611        // NaN sorts last; missing values form the suffix `sorted_indices[nm..]`
2612        // (`_splitter.pyx:918-944`).
2613        sort_indices_by_feature(&mut sorted_indices, data.x, feat);
2614        let n_missing = sorted_indices
2615            .iter()
2616            .filter(|&&i| data.x[[i, feat]].is_nan())
2617            .count();
2618        let n_nonmissing = n - n_missing;
2619        if n_nonmissing == 0 {
2620            continue;
2621        }
2622        let has_missing = n_missing > 0;
2623        let missing_slice = &sorted_indices[n_nonmissing..];
2624
2625        // Constant-feature band over the NON-missing samples only
2626        // (`_splitter.pyx:405`).
2627        let feat_min = data.x[[sorted_indices[0], feat]];
2628        let feat_max = data.x[[sorted_indices[n_nonmissing - 1], feat]];
2629        if feat_max <= feat_min + threshold_band {
2630            continue;
2631        }
2632
2633        // The missing block's target sum / sum-of-squares (for MSE/FriedmanMSE).
2634        let missing_sum: F = missing_slice
2635            .iter()
2636            .map(|&i| data.y[i])
2637            .fold(F::zero(), |a, b| a + b);
2638        let missing_sum_sq: F = missing_slice
2639            .iter()
2640            .map(|&i| data.y[i] * data.y[i])
2641            .fold(F::zero(), |a, b| a + b);
2642
2643        // Two passes when there are missing values: pass 0 missing→right,
2644        // pass 1 missing→left (`_splitter.pyx:428-431`); single pass otherwise
2645        // (byte-identical to the original no-missing scan).
2646        let n_searches = if has_missing { 2 } else { 1 };
2647
2648        for search in 0..n_searches {
2649            let missing_to_left = search == 1;
2650
2651            let mut left_sum = F::zero();
2652            let mut left_sum_sq = F::zero();
2653            let mut left_nm: usize = 0;
2654
2655            for split_pos in 0..n_nonmissing - 1 {
2656                let idx = sorted_indices[split_pos];
2657                let val = data.y[idx];
2658                left_sum = left_sum + val;
2659                left_sum_sq = left_sum_sq + val * val;
2660                left_nm += 1;
2661
2662                // Adjacent sorted (non-missing) values must differ by more than
2663                // FEATURE_THRESHOLD (sklearn's `next_p` skip, `_splitter.pyx`).
2664                let next_idx = sorted_indices[split_pos + 1];
2665                if data.x[[next_idx, feat]] <= data.x[[idx, feat]] + threshold_band {
2666                    continue;
2667                }
2668
2669                // Sample counts with the missing block folded into the chosen side.
2670                let (left_n, right_n) = if missing_to_left {
2671                    (left_nm + n_missing, n_nonmissing - left_nm)
2672                } else {
2673                    (left_nm, n_nonmissing - left_nm + n_missing)
2674                };
2675                if left_n < min_samples_leaf || right_n < min_samples_leaf {
2676                    continue;
2677                }
2678
2679                let score = regression_split_score(
2680                    data,
2681                    &sorted_indices,
2682                    n_nonmissing,
2683                    missing_slice,
2684                    left_nm,
2685                    left_sum,
2686                    left_sum_sq,
2687                    missing_sum,
2688                    missing_sum_sq,
2689                    parent_sum,
2690                    parent_sum_sq,
2691                    parent_mse,
2692                    parent_impurity,
2693                    n_f,
2694                    missing_to_left,
2695                );
2696
2697                if score > best_score {
2698                    best_score = score;
2699                    best_feature = feat;
2700                    best_missing_left = if has_missing { missing_to_left } else { false };
2701                    best_threshold = (data.x[[idx, feat]] + data.x[[next_idx, feat]])
2702                        / F::from(2.0).unwrap_or_else(F::one);
2703                }
2704            }
2705        }
2706
2707        // Extra candidate: ALL non-missing left, ALL missing right (threshold
2708        // = +∞, missing→right; `_splitter.pyx:498-519`).
2709        if has_missing {
2710            let left_n = n_nonmissing;
2711            let right_n = n_missing;
2712            if left_n >= min_samples_leaf && right_n >= min_samples_leaf {
2713                let left_slice = &sorted_indices[..n_nonmissing];
2714                let nm_sum = parent_sum - missing_sum;
2715                let nm_sum_sq = parent_sum_sq - missing_sum_sq;
2716                let score = regression_partitioned_score(
2717                    data,
2718                    left_slice,
2719                    missing_slice,
2720                    nm_sum,
2721                    nm_sum_sq,
2722                    missing_sum,
2723                    missing_sum_sq,
2724                    parent_mse,
2725                    parent_impurity,
2726                    n_f,
2727                );
2728                if score > best_score {
2729                    best_score = score;
2730                    best_feature = feat;
2731                    best_missing_left = false;
2732                    best_threshold = F::infinity();
2733                }
2734            }
2735        }
2736    }
2737
2738    if best_score > F::zero() {
2739        Some((
2740            best_feature,
2741            best_threshold,
2742            best_score * n_f,
2743            best_missing_left,
2744        ))
2745    } else {
2746        None
2747    }
2748}
2749
2750/// Per-criterion split score for a regression split at the non-missing scan
2751/// position `left_nm`, with the missing block routed to the side given by
2752/// `missing_to_left`.
2753///
2754/// The left non-missing prefix is `sorted_indices[..left_nm]`, the right
2755/// non-missing suffix is `sorted_indices[left_nm..n_nonmissing]`, and
2756/// `missing_slice` is the missing block. For MSE / FriedmanMSE the child stats
2757/// are computed from running sums (`left_sum`/`left_sum_sq` are the left
2758/// non-missing prefix sums, the missing block's `missing_sum`/`missing_sum_sq`
2759/// fold into the chosen side) — byte-identical to the prior scan when
2760/// `missing_slice` is empty. For MAE / Poisson the explicit child index lists
2761/// are built so the median / deviance helpers see exactly the child's samples.
2762#[allow(
2763    clippy::too_many_arguments,
2764    reason = "threads running sums + the missing block + parent stats for all four criteria"
2765)]
2766fn regression_split_score<F: Float>(
2767    data: &RegressionData<'_, F>,
2768    sorted_indices: &[usize],
2769    n_nonmissing: usize,
2770    missing_slice: &[usize],
2771    left_nm: usize,
2772    left_sum: F,
2773    left_sum_sq: F,
2774    missing_sum: F,
2775    missing_sum_sq: F,
2776    parent_sum: F,
2777    parent_sum_sq: F,
2778    parent_mse: F,
2779    parent_impurity: F,
2780    n_f: F,
2781    missing_to_left: bool,
2782) -> F {
2783    // Right non-missing prefix sums (parent − left, before folding the missing
2784    // block in).
2785    let right_nm_sum = parent_sum - left_sum - missing_sum;
2786    let right_nm_sum_sq = parent_sum_sq - left_sum_sq - missing_sum_sq;
2787
2788    // Fold the missing block's sums into the chosen child.
2789    let (l_sum, l_sum_sq, r_sum, r_sum_sq) = if missing_to_left {
2790        (
2791            left_sum + missing_sum,
2792            left_sum_sq + missing_sum_sq,
2793            right_nm_sum,
2794            right_nm_sum_sq,
2795        )
2796    } else {
2797        (
2798            left_sum,
2799            left_sum_sq,
2800            right_nm_sum + missing_sum,
2801            right_nm_sum_sq + missing_sum_sq,
2802        )
2803    };
2804
2805    match data.criterion {
2806        RegressionCriterion::Mse | RegressionCriterion::FriedmanMse => {
2807            let nm_left = left_nm
2808                + if missing_to_left {
2809                    missing_slice.len()
2810                } else {
2811                    0
2812                };
2813            let nm_right = (n_nonmissing - left_nm)
2814                + if missing_to_left {
2815                    0
2816                } else {
2817                    missing_slice.len()
2818                };
2819            let left_n_f = F::from(nm_left).unwrap_or_else(F::one);
2820            let right_n_f = F::from(nm_right).unwrap_or_else(F::one);
2821            match data.criterion {
2822                RegressionCriterion::FriedmanMse => {
2823                    let diff = right_n_f * l_sum - left_n_f * r_sum;
2824                    diff * diff / (left_n_f * right_n_f * n_f)
2825                }
2826                _ => {
2827                    let left_mean = l_sum / left_n_f;
2828                    let left_mse = l_sum_sq / left_n_f - left_mean * left_mean;
2829                    let right_mean = r_sum / right_n_f;
2830                    let right_mse = r_sum_sq / right_n_f - right_mean * right_mean;
2831                    let weighted_child_mse = (left_n_f * left_mse + right_n_f * right_mse) / n_f;
2832                    parent_mse - weighted_child_mse
2833                }
2834            }
2835        }
2836        RegressionCriterion::AbsoluteError | RegressionCriterion::Poisson => {
2837            // Build the explicit child index lists (median / Poisson deviance
2838            // need the actual samples, not just sums).
2839            let (left_idx, right_idx) =
2840                build_regression_children(sorted_indices, n_nonmissing, left_nm, missing_to_left);
2841            regression_partitioned_score(
2842                data,
2843                &left_idx,
2844                &right_idx,
2845                F::zero(),
2846                F::zero(),
2847                F::zero(),
2848                F::zero(),
2849                parent_mse,
2850                parent_impurity,
2851                n_f,
2852            )
2853        }
2854    }
2855}
2856
2857/// Build the explicit `(left, right)` child index lists from the sorted
2858/// non-missing scan position `left_nm` and the missing suffix, routing the
2859/// missing block to `missing_to_left`.
2860fn build_regression_children(
2861    sorted_indices: &[usize],
2862    n_nonmissing: usize,
2863    left_nm: usize,
2864    missing_to_left: bool,
2865) -> (Vec<usize>, Vec<usize>) {
2866    let nm_left = &sorted_indices[..left_nm];
2867    let nm_right = &sorted_indices[left_nm..n_nonmissing];
2868    let missing = &sorted_indices[n_nonmissing..];
2869    let mut left: Vec<usize> = nm_left.to_vec();
2870    let mut right: Vec<usize> = nm_right.to_vec();
2871    if missing_to_left {
2872        left.extend_from_slice(missing);
2873    } else {
2874        right.extend_from_slice(missing);
2875    }
2876    (left, right)
2877}
2878
2879/// Per-criterion regression split score from explicit child index lists.
2880///
2881/// For MAE / Poisson the median / deviance helpers run over the exact child
2882/// indices; the `*_sum*` arguments are unused there (passed `0`). For MSE /
2883/// FriedmanMSE the running sums are recomputed from the child lists.
2884#[allow(
2885    clippy::too_many_arguments,
2886    reason = "shared scorer over explicit child index lists for all four criteria"
2887)]
2888fn regression_partitioned_score<F: Float>(
2889    data: &RegressionData<'_, F>,
2890    left_idx: &[usize],
2891    right_idx: &[usize],
2892    _left_sum: F,
2893    _left_sum_sq: F,
2894    _missing_sum: F,
2895    _missing_sum_sq: F,
2896    parent_mse: F,
2897    parent_impurity: F,
2898    n_f: F,
2899) -> F {
2900    let left_n_f = F::from(left_idx.len()).unwrap_or_else(F::one);
2901    let right_n_f = F::from(right_idx.len()).unwrap_or_else(F::one);
2902    match data.criterion {
2903        RegressionCriterion::Mse => {
2904            let left_mean = mean_value(data.y, left_idx);
2905            let left_mse = mse_for_indices(data.y, left_idx, left_mean);
2906            let right_mean = mean_value(data.y, right_idx);
2907            let right_mse = mse_for_indices(data.y, right_idx, right_mean);
2908            let weighted_child_mse = (left_n_f * left_mse + right_n_f * right_mse) / n_f;
2909            parent_mse - weighted_child_mse
2910        }
2911        RegressionCriterion::FriedmanMse => {
2912            let left_sum = mean_value(data.y, left_idx) * left_n_f;
2913            let right_sum = mean_value(data.y, right_idx) * right_n_f;
2914            let diff = right_n_f * left_sum - left_n_f * right_sum;
2915            diff * diff / (left_n_f * right_n_f * n_f)
2916        }
2917        RegressionCriterion::AbsoluteError => {
2918            let left_mae = mae_for_indices(data.y, left_idx);
2919            let right_mae = mae_for_indices(data.y, right_idx);
2920            let weighted_child_mae = (left_n_f * left_mae + right_n_f * right_mae) / n_f;
2921            parent_impurity - weighted_child_mae
2922        }
2923        RegressionCriterion::Poisson => {
2924            let left_dev = poisson_deviance_for_indices(data.y, left_idx);
2925            let right_dev = poisson_deviance_for_indices(data.y, right_idx);
2926            let weighted_child_dev = (left_n_f * left_dev + right_n_f * right_dev) / n_f;
2927            parent_impurity - weighted_child_dev
2928        }
2929    }
2930}
2931
2932/// Compute normalised feature importances from impurity decreases in the tree.
2933pub(crate) fn compute_feature_importances<F: Float>(
2934    nodes: &[Node<F>],
2935    n_features: usize,
2936    _total_samples: usize,
2937) -> Array1<F> {
2938    let mut importances = Array1::zeros(n_features);
2939    for node in nodes {
2940        if let Node::Split {
2941            feature,
2942            impurity_decrease,
2943            ..
2944        } = node
2945        {
2946            importances[*feature] = importances[*feature] + *impurity_decrease;
2947        }
2948    }
2949    let total: F = importances.iter().copied().fold(F::zero(), |a, b| a + b);
2950    if total > F::zero() {
2951        importances.mapv_inplace(|v| v / total);
2952    }
2953    importances
2954}
2955
2956/// Aggregate per-tree feature importances across an ensemble.
2957///
2958/// - `trees`: the per-tree node lists.
2959/// - `feature_indices`: when `Some`, each tree was trained on a feature
2960///   subset; the tree-local feature indices are remapped through
2961///   `feature_indices[t]` back to the original feature space. When `None`,
2962///   every tree uses the full feature space directly.
2963/// - `weights`: when `Some`, each tree's importances are scaled by
2964///   `weights[t]` before aggregation (used by AdaBoost). When `None`,
2965///   uniform weights of 1.
2966/// - `n_features`: width of the original feature space.
2967///
2968/// Returns an `Array1<F>` of length `n_features`, normalized to sum to 1
2969/// (or all zeros if no splits had any impurity decrease).
2970pub(crate) fn aggregate_tree_importances<F: Float>(
2971    trees: &[Vec<Node<F>>],
2972    feature_indices: Option<&[Vec<usize>]>,
2973    weights: Option<&[F]>,
2974    n_features: usize,
2975) -> Array1<F> {
2976    let mut total_imp = Array1::<F>::zeros(n_features);
2977    for (t, nodes) in trees.iter().enumerate() {
2978        let w = weights.map_or(F::one(), |ws| ws[t]);
2979        for node in nodes {
2980            if let Node::Split {
2981                feature,
2982                impurity_decrease,
2983                ..
2984            } = node
2985            {
2986                let original_feature = match feature_indices {
2987                    Some(map) => map[t][*feature],
2988                    None => *feature,
2989                };
2990                total_imp[original_feature] = total_imp[original_feature] + w * *impurity_decrease;
2991            }
2992        }
2993    }
2994    let total: F = total_imp.iter().copied().fold(F::zero(), |a, b| a + b);
2995    if total > F::zero() {
2996        total_imp.mapv_inplace(|v| v / total);
2997    }
2998    total_imp
2999}
3000
3001// ---------------------------------------------------------------------------
3002// Public builders for forest usage
3003// ---------------------------------------------------------------------------
3004
3005/// Build a classification tree with a subset of features considered per split.
3006///
3007/// Used internally by `RandomForestClassifier` to build individual trees.
3008#[allow(clippy::too_many_arguments)]
3009pub(crate) fn build_classification_tree_with_feature_subset<F: Float>(
3010    x: &Array2<F>,
3011    y: &[usize],
3012    n_classes: usize,
3013    indices: &[usize],
3014    feature_indices: &[usize],
3015    params: &TreeParams,
3016    criterion: ClassificationCriterion,
3017) -> Vec<Node<F>> {
3018    let data = ClassificationData {
3019        x,
3020        y,
3021        n_classes,
3022        feature_indices: Some(feature_indices),
3023        max_features_per_split: None,
3024        criterion,
3025        // Forests do not expose `class_weight`; unweighted path (byte-identical).
3026        sample_weight: None,
3027        min_weight_leaf: F::zero(),
3028    };
3029    let mut nodes = Vec::new();
3030    let gate = ImpurityGate::disabled(indices.len());
3031    // Forests do not expose `ccp_alpha`; pass `None` for the prune metadata so
3032    // the trees stay byte-identical and no side vec is allocated.
3033    build_classification_tree(&data, indices, &mut nodes, None, 0, params, &gate, None);
3034    nodes
3035}
3036
3037/// Build a **weighted** classification tree over ALL samples with a fixed
3038/// feature subset.
3039///
3040/// Mirrors [`build_classification_tree_with_feature_subset`] exactly except it
3041/// threads `sample_weight: Some(sample_weight)` into [`ClassificationData`] and
3042/// builds over the full sample set (`indices = 0..n_samples`, no resampling).
3043/// The node class counts, gini/entropy, and the leaf class distribution are all
3044/// weighted by `sample_weight` (the oracle-verified `class_weight` path,
3045/// `.design/tree/decision_tree.md` REQ-7).
3046///
3047/// This is the substrate for AdaBoost's deterministic weighted base fit:
3048/// scikit-learn fits each round's stump on the weighted data directly —
3049/// `estimator.fit(X, y, sample_weight=sample_weight)`
3050/// (`sklearn/ensemble/_weight_boosting.py:664` — SAMME `_boost_discrete`,
3051/// `:605` — SAMME.R `_boost_real`) — with NO bootstrap/RNG in the classifier
3052/// path. `min_weight_leaf` is `F::zero()` because AdaBoost sets no
3053/// `min_weight_fraction_leaf`.
3054///
3055/// `sample_weight` is indexed by the ORIGINAL sample index (parallel to `y`),
3056/// length `n_samples`.
3057#[allow(clippy::too_many_arguments)]
3058pub(crate) fn build_weighted_classification_tree_with_feature_subset<F: Float>(
3059    x: &Array2<F>,
3060    y: &[usize],
3061    n_classes: usize,
3062    sample_weight: &[F],
3063    feature_indices: &[usize],
3064    params: &TreeParams,
3065    criterion: ClassificationCriterion,
3066) -> Vec<Node<F>> {
3067    let n_samples = y.len();
3068    let data = ClassificationData {
3069        x,
3070        y,
3071        n_classes,
3072        feature_indices: Some(feature_indices),
3073        max_features_per_split: None,
3074        criterion,
3075        // AdaBoost fits the stump WEIGHTED on all samples (`_weight_boosting.py:664`).
3076        sample_weight: Some(sample_weight),
3077        // AdaBoost sets no `min_weight_fraction_leaf`, so the weighted leaf-mass
3078        // gate never rejects.
3079        min_weight_leaf: F::zero(),
3080    };
3081    // All samples — no resampling (sklearn fits on the full weighted data).
3082    let indices: Vec<usize> = (0..n_samples).collect();
3083    let mut nodes = Vec::new();
3084    let gate = ImpurityGate::disabled(indices.len());
3085    build_classification_tree(&data, &indices, &mut nodes, None, 0, params, &gate, None);
3086    nodes
3087}
3088
3089/// Build a classification tree with **per-split** random feature sampling.
3090///
3091/// At every split node, a fresh random subset of `max_features` features is
3092/// drawn from the full `0..n_features` pool. This is the Breiman (2001)
3093/// RandomForest behaviour and matches scikit-learn.
3094///
3095/// Used by `RandomForestClassifier` and `ExtraTreesClassifier`.
3096#[allow(clippy::too_many_arguments)]
3097pub(crate) fn build_classification_tree_per_split_features<F: Float>(
3098    x: &Array2<F>,
3099    y: &[usize],
3100    n_classes: usize,
3101    indices: &[usize],
3102    max_features: usize,
3103    params: &TreeParams,
3104    criterion: ClassificationCriterion,
3105    seed: u64,
3106) -> Vec<Node<F>> {
3107    let data = ClassificationData {
3108        x,
3109        y,
3110        n_classes,
3111        feature_indices: None,
3112        max_features_per_split: Some(max_features),
3113        criterion,
3114        // Forests do not expose `class_weight`; unweighted path (byte-identical).
3115        sample_weight: None,
3116        min_weight_leaf: F::zero(),
3117    };
3118    let mut rng = StdRng::seed_from_u64(seed);
3119    let mut nodes = Vec::new();
3120    let gate = ImpurityGate::disabled(indices.len());
3121    build_classification_tree(
3122        &data,
3123        indices,
3124        &mut nodes,
3125        None,
3126        0,
3127        params,
3128        &gate,
3129        Some(&mut rng),
3130    );
3131    nodes
3132}
3133
3134/// Build a regression tree with a subset of features considered per split.
3135pub(crate) fn build_regression_tree_with_feature_subset<F: Float>(
3136    x: &Array2<F>,
3137    y: &Array1<F>,
3138    indices: &[usize],
3139    feature_indices: &[usize],
3140    params: &TreeParams,
3141) -> Vec<Node<F>> {
3142    let data = RegressionData {
3143        x,
3144        y,
3145        feature_indices: Some(feature_indices),
3146        max_features_per_split: None,
3147        // Forest/extra-trees ensembles use the MSE (squared_error) criterion.
3148        criterion: RegressionCriterion::Mse,
3149    };
3150    let mut nodes = Vec::new();
3151    let gate = ImpurityGate::disabled(indices.len());
3152    // Forests do not expose `ccp_alpha`; pass `None` for the prune metadata.
3153    build_regression_tree(&data, indices, &mut nodes, None, 0, params, &gate, None);
3154    nodes
3155}
3156
3157/// Build a regression tree with **per-split** random feature sampling
3158/// (Breiman 2001 RandomForest, sklearn-equivalent).
3159///
3160/// Used by `RandomForestRegressor` and `ExtraTreesRegressor`.
3161pub(crate) fn build_regression_tree_per_split_features<F: Float>(
3162    x: &Array2<F>,
3163    y: &Array1<F>,
3164    indices: &[usize],
3165    max_features: usize,
3166    params: &TreeParams,
3167    seed: u64,
3168) -> Vec<Node<F>> {
3169    let data = RegressionData {
3170        x,
3171        y,
3172        feature_indices: None,
3173        max_features_per_split: Some(max_features),
3174        // Forest/extra-trees ensembles use the MSE (squared_error) criterion.
3175        criterion: RegressionCriterion::Mse,
3176    };
3177    let mut rng = StdRng::seed_from_u64(seed);
3178    let mut nodes = Vec::new();
3179    let gate = ImpurityGate::disabled(indices.len());
3180    build_regression_tree(
3181        &data,
3182        indices,
3183        &mut nodes,
3184        None,
3185        0,
3186        params,
3187        &gate,
3188        Some(&mut rng),
3189    );
3190    nodes
3191}
3192
3193// ---------------------------------------------------------------------------
3194// Best-first growth (`max_leaf_nodes`, sklearn `BestFirstTreeBuilder`)
3195// ---------------------------------------------------------------------------
3196
3197/// A frontier record awaiting expansion in the best-first builder, the native
3198/// analog of sklearn's `FrontierRecord` (`_tree.pyx:374`).
3199///
3200/// Records carry their candidate split (already found, with the tree-normalized
3201/// `improvement` used to order the max-heap, exactly as sklearn stores
3202/// `split.improvement` in `_add_split_node`, `_tree.pyx:670`) plus the
3203/// `arena_idx` slot reserved for this node so the parent can wire its child
3204/// pointer once the record is materialized. `is_leaf` records ascertain at
3205/// push time that the node cannot be split (depth / min_samples / purity /
3206/// `min_impurity_decrease` gate) and carry `improvement = 0`.
3207struct FrontierRecord<F> {
3208    /// Reserved arena slot for this node (filled when the record is popped).
3209    arena_idx: usize,
3210    /// Sample indices reaching this node.
3211    indices: Vec<usize>,
3212    /// Depth of this node (root = 0).
3213    depth: usize,
3214    /// Tree-normalized impurity improvement of this node's best split, used to
3215    /// order the frontier max-heap (`_compare_records`, `_tree.pyx:392`).
3216    improvement: F,
3217    /// This node's candidate split
3218    /// `(feature, threshold, best_impurity_decrease, missing_go_to_left)`
3219    /// (`best_impurity_decrease` is the finder's `improvement_inner · n_node`).
3220    /// `None` ⇒ the node is a leaf (no expandable split).
3221    split: Option<(usize, F, F, bool)>,
3222    /// Monotone insertion counter: the tie-break for equal `improvement`
3223    /// (lower id popped first), documenting sklearn's heap which is unstable on
3224    /// exact ties.
3225    seq: u64,
3226}
3227
3228/// A node in the best-first arena, indexed by `arena_idx`. Split nodes record
3229/// their child arena slots once both children have been pushed; leaves carry
3230/// the materialized leaf value/distribution.
3231enum BuildNode<F> {
3232    /// An internal split node, with arena slots for its children.
3233    Split {
3234        feature: usize,
3235        threshold: F,
3236        impurity_decrease: F,
3237        n_samples: usize,
3238        left: usize,
3239        right: usize,
3240        /// Pruning metadata for this node's collapse leaf (only when recording).
3241        meta: Option<NodeMeta<F>>,
3242    },
3243    /// A leaf node carrying its prediction value and (classifier) distribution.
3244    Leaf {
3245        value: F,
3246        class_distribution: Option<Vec<F>>,
3247        n_samples: usize,
3248        meta: Option<NodeMeta<F>>,
3249    },
3250}
3251
3252/// Serialize the best-first arena into a flat depth-first pre-order
3253/// `Vec<Node<F>>` (root = 0, left child before right), the layout the
3254/// depth-first builder produces and that `predict`/`nodes()` understand. When
3255/// `meta_out` is `Some`, the per-node pruning metadata is emitted in the same
3256/// pre-order so `ccp_alpha` pruning (which runs afterwards) stays index-aligned.
3257fn serialize_best_first_arena<F: Float>(
3258    arena: &[BuildNode<F>],
3259    record_meta: bool,
3260) -> (Vec<Node<F>>, Vec<NodeMeta<F>>) {
3261    let mut nodes: Vec<Node<F>> = Vec::with_capacity(arena.len());
3262    let mut meta: Vec<NodeMeta<F>> = Vec::new();
3263    if arena.is_empty() {
3264        return (nodes, meta);
3265    }
3266    // (arena_idx, reserved slot in `nodes`).
3267    let mut stack: Vec<(usize, usize)> = Vec::new();
3268    let root_slot = nodes.len();
3269    nodes.push(placeholder_leaf::<F>());
3270    if record_meta {
3271        meta.push(NodeMeta {
3272            impurity: F::zero(),
3273            n_samples: 0,
3274            value: F::zero(),
3275            distribution: None,
3276            missing_go_to_left: false,
3277        });
3278    }
3279    stack.push((0usize, root_slot));
3280
3281    while let Some((arena_idx, slot)) = stack.pop() {
3282        match &arena[arena_idx] {
3283            BuildNode::Leaf {
3284                value,
3285                class_distribution,
3286                n_samples,
3287                meta: node_meta,
3288            } => {
3289                nodes[slot] = Node::Leaf {
3290                    value: *value,
3291                    class_distribution: class_distribution.clone(),
3292                    n_samples: *n_samples,
3293                };
3294                if record_meta && let Some(m) = node_meta {
3295                    meta[slot] = m.clone();
3296                }
3297            }
3298            BuildNode::Split {
3299                feature,
3300                threshold,
3301                impurity_decrease,
3302                n_samples,
3303                left,
3304                right,
3305                meta: node_meta,
3306            } => {
3307                let left_slot = nodes.len();
3308                nodes.push(placeholder_leaf::<F>());
3309                let right_slot = nodes.len();
3310                nodes.push(placeholder_leaf::<F>());
3311                if record_meta {
3312                    meta.push(NodeMeta {
3313                        impurity: F::zero(),
3314                        n_samples: 0,
3315                        value: F::zero(),
3316                        distribution: None,
3317                        missing_go_to_left: false,
3318                    });
3319                    meta.push(NodeMeta {
3320                        impurity: F::zero(),
3321                        n_samples: 0,
3322                        value: F::zero(),
3323                        distribution: None,
3324                        missing_go_to_left: false,
3325                    });
3326                }
3327                nodes[slot] = Node::Split {
3328                    feature: *feature,
3329                    threshold: *threshold,
3330                    left: left_slot,
3331                    right: right_slot,
3332                    impurity_decrease: *impurity_decrease,
3333                    n_samples: *n_samples,
3334                };
3335                if record_meta && let Some(m) = node_meta {
3336                    meta[slot] = m.clone();
3337                }
3338                // Push right first so left is processed first (pre-order).
3339                stack.push((*right, right_slot));
3340                stack.push((*left, left_slot));
3341            }
3342        }
3343    }
3344    (nodes, meta)
3345}
3346
3347/// Pop the frontier record with the highest `improvement` (sklearn's max-heap;
3348/// `_compare_records` orders by `improvement`, the max is popped first,
3349/// `_tree.pyx:392`). On an exact `improvement` tie the lowest `seq`
3350/// (insertion order) is returned — a deterministic tie-break documenting that
3351/// sklearn's C++ heap is NOT stably ordered on exact ties (on the oracle sets
3352/// the improvements are distinct, so the depth-first path is unaffected).
3353///
3354/// Uses a NaN-safe `partial_cmp` fallback so it never panics (R-CODE-2).
3355#[allow(
3356    clippy::type_complexity,
3357    reason = "frontier is a plain Vec scanned linearly; a BinaryHeap would need an Ord wrapper for F"
3358)]
3359fn pop_best_frontier<F: Float>(frontier: &mut Vec<FrontierRecord<F>>) -> Option<FrontierRecord<F>> {
3360    if frontier.is_empty() {
3361        return None;
3362    }
3363    let mut best = 0usize;
3364    for i in 1..frontier.len() {
3365        let cur = &frontier[i];
3366        let cur_best = &frontier[best];
3367        let better = match cur.improvement.partial_cmp(&cur_best.improvement) {
3368            Some(std::cmp::Ordering::Greater) => true,
3369            Some(std::cmp::Ordering::Equal) => cur.seq < cur_best.seq,
3370            _ => false,
3371        };
3372        if better {
3373            best = i;
3374        }
3375    }
3376    Some(frontier.swap_remove(best))
3377}
3378
3379/// Build a classification tree best-first (`max_leaf_nodes`), the native analog
3380/// of sklearn's `BestFirstTreeBuilder.build` (`_tree.pyx:427`).
3381///
3382/// `max_leaf_nodes` is `k`: the grown tree has at most `k` leaves (`2k−1`
3383/// nodes). Reuses [`find_best_classification_split`] and the [`ImpurityGate`].
3384#[allow(
3385    clippy::too_many_arguments,
3386    reason = "mirrors the depth-first builder's argument set plus max_leaf_nodes"
3387)]
3388fn build_classification_tree_best_first<F: Float>(
3389    data: &ClassificationData<'_, F>,
3390    indices: &[usize],
3391    nodes: &mut Vec<Node<F>>,
3392    meta: Option<&mut Vec<NodeMeta<F>>>,
3393    params: &TreeParams,
3394    gate: &ImpurityGate<F>,
3395    max_leaf_nodes: usize,
3396) {
3397    let record_meta = meta.is_some();
3398    let mut arena: Vec<BuildNode<F>> = Vec::new();
3399    let n_total_f = F::from(gate.n_total).unwrap_or_else(F::one);
3400    let mut seq: u64 = 0;
3401
3402    // Evaluate a node: compute its class counts, decide whether it can split
3403    // (depth / min_samples / purity), find its best split + gate it, and build
3404    // the frontier record (split or leaf) for the reserved `arena_idx` slot.
3405    let evaluate =
3406        |arena_idx: usize, node_indices: Vec<usize>, depth: usize, seq: u64| -> FrontierRecord<F> {
3407            let n = node_indices.len();
3408            let mut class_counts = vec![0usize; data.n_classes];
3409            for &i in &node_indices {
3410                class_counts[data.y[i]] += 1;
3411            }
3412            let cannot_split = n < params.min_samples_split
3413                || params.max_depth.is_some_and(|d| depth >= d)
3414                || class_counts.iter().filter(|&&c| c > 0).count() <= 1;
3415
3416            let split = if cannot_split {
3417                None
3418            } else {
3419                // No per-split RNG feature sampling on the estimator surface.
3420                find_best_classification_split(data, &node_indices, params.min_samples_leaf, None)
3421                    .filter(|&(_, _, best_impurity_decrease, _)| {
3422                        let improvement = best_impurity_decrease / n_total_f;
3423                        !gate.rejects(improvement)
3424                    })
3425            };
3426
3427            let improvement = split.map_or(F::zero(), |(_, _, bid, _)| bid / n_total_f);
3428            FrontierRecord {
3429                arena_idx,
3430                indices: node_indices,
3431                depth,
3432                improvement,
3433                split,
3434                seq,
3435            }
3436        };
3437
3438    // Reserve the root slot and seed the frontier.
3439    arena.push(placeholder_build_leaf::<F>());
3440    let mut frontier: Vec<FrontierRecord<F>> = vec![evaluate(0, indices.to_vec(), 0, seq)];
3441    seq += 1;
3442
3443    // `max_split_nodes = max_leaf_nodes - 1` (sklearn, `_tree.pyx:457`): each
3444    // materialized split decrements it; when it hits 0 the remaining frontier
3445    // nodes become leaves.
3446    let mut max_split_nodes = max_leaf_nodes.saturating_sub(1) as isize;
3447
3448    while let Some(record) = pop_best_frontier(&mut frontier) {
3449        let is_leaf = record.split.is_none() || max_split_nodes <= 0;
3450
3451        if is_leaf {
3452            arena[record.arena_idx] =
3453                make_classification_build_leaf(data, &record.indices, record_meta);
3454            continue;
3455        }
3456
3457        // Expand: materialize this split, push both children.
3458        max_split_nodes -= 1;
3459        let (best_feature, best_threshold, best_impurity_decrease, missing_go_to_left) =
3460            match record.split {
3461                Some(s) => s,
3462                None => continue,
3463            };
3464        let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
3465            &record.indices,
3466            data.x,
3467            best_feature,
3468            best_threshold,
3469            missing_go_to_left,
3470        );
3471
3472        let left_slot = arena.len();
3473        arena.push(placeholder_build_leaf::<F>());
3474        let right_slot = arena.len();
3475        arena.push(placeholder_build_leaf::<F>());
3476
3477        let node_meta = if record_meta {
3478            let n = record.indices.len();
3479            let (majority_class, distribution, impurity) = if let Some(sw) = data.sample_weight {
3480                let (wc, total) =
3481                    weighted_class_counts(&record.indices, data.y, data.n_classes, sw);
3482                let (mc, dist) = weighted_classification_node_value::<F>(&wc, total);
3483                (
3484                    mc,
3485                    dist,
3486                    weighted_compute_impurity::<F>(&wc, total, data.criterion),
3487                )
3488            } else {
3489                let mut class_counts = vec![0usize; data.n_classes];
3490                for &i in &record.indices {
3491                    class_counts[data.y[i]] += 1;
3492                }
3493                let (mc, dist) = classification_node_value::<F>(&class_counts, data.n_classes, n);
3494                (
3495                    mc,
3496                    dist,
3497                    compute_impurity::<F>(&class_counts, n, data.criterion),
3498                )
3499            };
3500            Some(NodeMeta {
3501                impurity,
3502                n_samples: n,
3503                value: F::from(majority_class).unwrap_or_else(F::zero),
3504                distribution: Some(distribution),
3505                missing_go_to_left,
3506            })
3507        } else {
3508            None
3509        };
3510
3511        arena[record.arena_idx] = BuildNode::Split {
3512            feature: best_feature,
3513            threshold: best_threshold,
3514            impurity_decrease: best_impurity_decrease,
3515            n_samples: record.indices.len(),
3516            left: left_slot,
3517            right: right_slot,
3518            meta: node_meta,
3519        };
3520
3521        frontier.push(evaluate(left_slot, left_indices, record.depth + 1, seq));
3522        seq += 1;
3523        frontier.push(evaluate(right_slot, right_indices, record.depth + 1, seq));
3524        seq += 1;
3525    }
3526
3527    let (built, built_meta) = serialize_best_first_arena(&arena, record_meta);
3528    *nodes = built;
3529    if let Some(meta) = meta {
3530        *meta = built_meta;
3531    }
3532}
3533
3534/// Materialize a classification leaf [`BuildNode`] from a node's samples.
3535///
3536/// Uses WEIGHTED class counts when `data.sample_weight` is set (`class_weight`
3537/// path); byte-identical to the prior unweighted maker otherwise.
3538fn make_classification_build_leaf<F: Float>(
3539    data: &ClassificationData<'_, F>,
3540    node_indices: &[usize],
3541    record_meta: bool,
3542) -> BuildNode<F> {
3543    let n = node_indices.len();
3544    let (majority_class, distribution, impurity) = if let Some(sw) = data.sample_weight {
3545        let (wc, total) = weighted_class_counts(node_indices, data.y, data.n_classes, sw);
3546        let (mc, dist) = weighted_classification_node_value::<F>(&wc, total);
3547        (
3548            mc,
3549            dist,
3550            weighted_compute_impurity::<F>(&wc, total, data.criterion),
3551        )
3552    } else {
3553        let mut class_counts = vec![0usize; data.n_classes];
3554        for &i in node_indices {
3555            class_counts[data.y[i]] += 1;
3556        }
3557        let (mc, dist) = classification_node_value::<F>(&class_counts, data.n_classes, n);
3558        (
3559            mc,
3560            dist,
3561            compute_impurity::<F>(&class_counts, n, data.criterion),
3562        )
3563    };
3564    let value = F::from(majority_class).unwrap_or_else(F::zero);
3565    let meta = if record_meta {
3566        Some(NodeMeta {
3567            impurity,
3568            n_samples: n,
3569            value,
3570            distribution: Some(distribution.clone()),
3571            // Leaf node: no missing-value routing.
3572            missing_go_to_left: false,
3573        })
3574    } else {
3575        None
3576    };
3577    BuildNode::Leaf {
3578        value,
3579        class_distribution: Some(distribution),
3580        n_samples: n,
3581        meta,
3582    }
3583}
3584
3585/// Build a regression tree best-first (`max_leaf_nodes`), the native analog of
3586/// sklearn's `BestFirstTreeBuilder.build` (`_tree.pyx:427`).
3587///
3588/// Reuses [`find_best_regression_split`] and the [`ImpurityGate`]; the friedman
3589/// improvement normalization (`/n_node` vs `/N`) mirrors the depth-first gate.
3590#[allow(
3591    clippy::too_many_arguments,
3592    reason = "mirrors the depth-first builder's argument set plus max_leaf_nodes"
3593)]
3594fn build_regression_tree_best_first<F: Float>(
3595    data: &RegressionData<'_, F>,
3596    indices: &[usize],
3597    nodes: &mut Vec<Node<F>>,
3598    meta: Option<&mut Vec<NodeMeta<F>>>,
3599    params: &TreeParams,
3600    gate: &ImpurityGate<F>,
3601    max_leaf_nodes: usize,
3602) {
3603    let record_meta = meta.is_some();
3604    let mut arena: Vec<BuildNode<F>> = Vec::new();
3605    let mut seq: u64 = 0;
3606
3607    let evaluate =
3608        |arena_idx: usize, node_indices: Vec<usize>, depth: usize, seq: u64| -> FrontierRecord<F> {
3609            let n = node_indices.len();
3610            let parent_impurity = regression_node_impurity(data.y, &node_indices, data.criterion);
3611            let cannot_split = n < params.min_samples_split
3612                || params.max_depth.is_some_and(|d| depth >= d)
3613                || parent_impurity <= F::epsilon();
3614
3615            let split = if cannot_split {
3616                None
3617            } else {
3618                find_best_regression_split(data, &node_indices, params.min_samples_leaf, None)
3619                    .filter(|&(_, _, best_impurity_decrease, _)| {
3620                        // friedman_mse improvement is already tree-normalized per
3621                        // `_criterion.pyx:1573` (`/n_node`); the rest divide by N.
3622                        let denom = match data.criterion {
3623                            RegressionCriterion::FriedmanMse => n,
3624                            _ => gate.n_total,
3625                        };
3626                        let denom_f = F::from(denom).unwrap_or_else(F::one);
3627                        let improvement = best_impurity_decrease / denom_f;
3628                        !gate.rejects(improvement)
3629                    })
3630            };
3631
3632            // Frontier ORDERING uses a numerically-stable two-pass recomputation of
3633            // the chosen split's tree-normalized improvement (the finder's
3634            // `bid / N` uses the naive `Σy²/n − mean²` variance whose catastrophic
3635            // cancellation can flip the relative order of two near-equal
3636            // improvements vs sklearn's running-sum criterion — e.g. the k=4
3637            // regressor oracle where two depth-2 nodes differ by ~2e-17). The
3638            // split itself (feature/threshold/stored `impurity_decrease`) is
3639            // UNCHANGED; only the heap key is recomputed stably.
3640            let improvement = split.map_or(F::zero(), |(feat, threshold, bid, mgl)| {
3641                stable_regression_improvement(
3642                    data,
3643                    &node_indices,
3644                    feat,
3645                    threshold,
3646                    mgl,
3647                    bid,
3648                    gate.n_total,
3649                )
3650            });
3651            FrontierRecord {
3652                arena_idx,
3653                indices: node_indices,
3654                depth,
3655                improvement,
3656                split,
3657                seq,
3658            }
3659        };
3660
3661    arena.push(placeholder_build_leaf::<F>());
3662    let mut frontier: Vec<FrontierRecord<F>> = vec![evaluate(0, indices.to_vec(), 0, seq)];
3663    seq += 1;
3664
3665    let mut max_split_nodes = max_leaf_nodes.saturating_sub(1) as isize;
3666
3667    while let Some(record) = pop_best_frontier(&mut frontier) {
3668        let is_leaf = record.split.is_none() || max_split_nodes <= 0;
3669
3670        if is_leaf {
3671            arena[record.arena_idx] =
3672                make_regression_build_leaf(data, &record.indices, record_meta);
3673            continue;
3674        }
3675
3676        max_split_nodes -= 1;
3677        let (best_feature, best_threshold, best_impurity_decrease, missing_go_to_left) =
3678            match record.split {
3679                Some(s) => s,
3680                None => continue,
3681            };
3682        let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
3683            &record.indices,
3684            data.x,
3685            best_feature,
3686            best_threshold,
3687            missing_go_to_left,
3688        );
3689
3690        let left_slot = arena.len();
3691        arena.push(placeholder_build_leaf::<F>());
3692        let right_slot = arena.len();
3693        arena.push(placeholder_build_leaf::<F>());
3694
3695        let node_meta = if record_meta {
3696            Some(NodeMeta {
3697                impurity: regression_node_impurity(data.y, &record.indices, data.criterion),
3698                n_samples: record.indices.len(),
3699                value: regression_leaf_value(data.y, &record.indices, data.criterion),
3700                distribution: None,
3701                missing_go_to_left,
3702            })
3703        } else {
3704            None
3705        };
3706
3707        arena[record.arena_idx] = BuildNode::Split {
3708            feature: best_feature,
3709            threshold: best_threshold,
3710            impurity_decrease: best_impurity_decrease,
3711            n_samples: record.indices.len(),
3712            left: left_slot,
3713            right: right_slot,
3714            meta: node_meta,
3715        };
3716
3717        frontier.push(evaluate(left_slot, left_indices, record.depth + 1, seq));
3718        seq += 1;
3719        frontier.push(evaluate(right_slot, right_indices, record.depth + 1, seq));
3720        seq += 1;
3721    }
3722
3723    let (built, built_meta) = serialize_best_first_arena(&arena, record_meta);
3724    *nodes = built;
3725    if let Some(meta) = meta {
3726        *meta = built_meta;
3727    }
3728}
3729
3730/// Numerically-stable tree-normalized improvement of a regression split, used
3731/// ONLY to order the best-first frontier max-heap.
3732///
3733/// `(feature, threshold)` are the finder's chosen split; `naive_bid` is the
3734/// finder's stored `best_impurity_decrease` (kept for the non-variance
3735/// criteria, which already use two-pass impurity helpers). For MSE / FriedmanMSE
3736/// the variance terms are recomputed with a two-pass centered sum-of-squares
3737/// (`Σ(y−mean)²/n`) so the heap key does not suffer the naive variance's
3738/// catastrophic cancellation. The returned value is the tree-normalized
3739/// improvement `(n_node/N)·(parent − Σ(n_child/n_node)·imp_child)` for
3740/// MSE/MAE/Poisson and the Friedman proxy `diff²/(n_L·n_R·n_node)` for
3741/// FriedmanMSE (matching the depth-first gate's `/N` vs `/n_node` denominators).
3742fn stable_regression_improvement<F: Float>(
3743    data: &RegressionData<'_, F>,
3744    node_indices: &[usize],
3745    feature: usize,
3746    threshold: F,
3747    missing_go_to_left: bool,
3748    naive_bid: F,
3749    n_total: usize,
3750) -> F {
3751    let n = node_indices.len();
3752    let n_f = F::from(n).unwrap_or_else(F::one);
3753    let n_total_f = F::from(n_total).unwrap_or_else(F::one);
3754    let (left, right): (Vec<usize>, Vec<usize>) =
3755        partition_with_missing(node_indices, data.x, feature, threshold, missing_go_to_left);
3756    let n_l = F::from(left.len()).unwrap_or_else(F::one);
3757    let n_r = F::from(right.len()).unwrap_or_else(F::one);
3758
3759    match data.criterion {
3760        RegressionCriterion::Mse => {
3761            let parent_var = centered_variance(data.y, node_indices);
3762            let left_var = centered_variance(data.y, &left);
3763            let right_var = centered_variance(data.y, &right);
3764            let inner = parent_var - (n_l / n_f) * left_var - (n_r / n_f) * right_var;
3765            (n_f / n_total_f) * inner
3766        }
3767        RegressionCriterion::FriedmanMse => {
3768            // Friedman proxy diff²/(n_L·n_R·n_node), diff = n_R·sum_L − n_L·sum_R,
3769            // using stable means (sum = mean·n) — already the tree-normalized
3770            // improvement (`_criterion.pyx:1573`), divided by n_node not N.
3771            let sum_l = mean_value(data.y, &left) * n_l;
3772            let sum_r = mean_value(data.y, &right) * n_r;
3773            let diff = n_r * sum_l - n_l * sum_r;
3774            diff * diff / (n_l * n_r * n_f)
3775        }
3776        // MAE / Poisson impurity helpers are already two-pass / stable; reuse
3777        // the finder's improvement (bid / N).
3778        RegressionCriterion::AbsoluteError | RegressionCriterion::Poisson => naive_bid / n_total_f,
3779    }
3780}
3781
3782/// Two-pass centered variance `Σ(y−mean)²/n` of the targets at `indices`
3783/// (numerically stable; avoids the naive `Σy²/n − mean²` cancellation).
3784fn centered_variance<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
3785    let n = indices.len();
3786    if n == 0 {
3787        return F::zero();
3788    }
3789    let mean = mean_value(y, indices);
3790    let sum_sq: F = indices
3791        .iter()
3792        .map(|&i| {
3793            let d = y[i] - mean;
3794            d * d
3795        })
3796        .fold(F::zero(), |a, b| a + b);
3797    sum_sq / F::from(n).unwrap_or_else(F::one)
3798}
3799
3800/// Materialize a regression leaf [`BuildNode`] from a node's samples.
3801fn make_regression_build_leaf<F: Float>(
3802    data: &RegressionData<'_, F>,
3803    node_indices: &[usize],
3804    record_meta: bool,
3805) -> BuildNode<F> {
3806    let n = node_indices.len();
3807    let value = regression_leaf_value(data.y, node_indices, data.criterion);
3808    let meta = if record_meta {
3809        Some(NodeMeta {
3810            impurity: regression_node_impurity(data.y, node_indices, data.criterion),
3811            n_samples: n,
3812            value,
3813            distribution: None,
3814            // Leaf node: no missing-value routing.
3815            missing_go_to_left: false,
3816        })
3817    } else {
3818        None
3819    };
3820    BuildNode::Leaf {
3821        value,
3822        class_distribution: None,
3823        n_samples: n,
3824        meta,
3825    }
3826}
3827
3828/// A placeholder leaf [`BuildNode`] reserving an arena slot before its real
3829/// contents are known (overwritten when its frontier record is popped).
3830fn placeholder_build_leaf<F: Float>() -> BuildNode<F> {
3831    BuildNode::Leaf {
3832        value: F::zero(),
3833        class_distribution: None,
3834        n_samples: 0,
3835        meta: None,
3836    }
3837}
3838
3839// ---------------------------------------------------------------------------
3840// Minimal cost-complexity pruning (`ccp_alpha`)
3841// ---------------------------------------------------------------------------
3842
3843/// Sentinel for "no parent" in the parent map (the root's parent), mirroring
3844/// sklearn's `_TREE_UNDEFINED` (`_tree.pyx`).
3845const CCP_NO_PARENT: usize = usize::MAX;
3846
3847/// Apply Minimal Cost-Complexity Pruning (Breiman weakest-link) to a grown tree
3848/// and return a NEW compacted flat `Vec<Node<F>>`.
3849///
3850/// This is the native analog of sklearn's `_cost_complexity_prune` +
3851/// `_build_pruned_tree_ccp` (`_tree.pyx:1649,1808`). For every node `t` the
3852/// resubstitution risk is `R(t) = impurity(t) · n_t / N`
3853/// (`r_node`, `_tree.pyx:1711`, uniform weights ⇒
3854/// `weighted_n_node_samples = n_t`, `total_sum_weights = N`). For a subtree the
3855/// branch risk `R(T_t)` is the sum of `R(leaf)` over its leaves, and the
3856/// effective alpha is `(R(t) − R(T_t)) / (n_leaves(T_t) − 1)`. The internal node
3857/// with the SMALLEST effective alpha is collapsed while that alpha is
3858/// `<= ccp_alpha` (sklearn `_AlphaPruner.stop_pruning` returns true — i.e. stop
3859/// — when `ccp_alpha < effective_alpha`, `_tree.pyx:1617`), recomputing after
3860/// each collapse, until the smallest effective alpha exceeds `ccp_alpha` or only
3861/// the root remains.
3862///
3863/// `meta` is index-aligned with `nodes` (built only when `ccp_alpha > 0`).
3864/// `n_total` is `N` (the whole tree's training-sample count). The returned tree
3865/// re-uses the surviving nodes' indices in a fresh pre-order-stable compaction
3866/// so the child pointers remain valid.
3867fn prune_ccp<F: Float>(
3868    nodes: &[Node<F>],
3869    meta: &[NodeMeta<F>],
3870    n_total: usize,
3871    ccp_alpha: F,
3872) -> (Vec<Node<F>>, Vec<NodeMeta<F>>) {
3873    let n_nodes = nodes.len();
3874    if n_nodes <= 1 {
3875        return (nodes.to_vec(), meta.to_vec());
3876    }
3877    let total_w = F::from(n_total).unwrap_or_else(F::one);
3878
3879    // Parent map + per-node `R(t)`.
3880    let mut parent = vec![CCP_NO_PARENT; n_nodes];
3881    let mut r_node = vec![F::zero(); n_nodes];
3882    for (i, node) in nodes.iter().enumerate() {
3883        let m = &meta[i];
3884        r_node[i] = m.impurity * F::from(m.n_samples).unwrap_or_else(F::zero) / total_w;
3885        if let Node::Split { left, right, .. } = node {
3886            parent[*left] = i;
3887            parent[*right] = i;
3888        }
3889    }
3890
3891    // `leaves_in_subtree[i]` — is node `i` currently a leaf of the pruned tree?
3892    // `in_subtree[i]` — does node `i` survive in the pruned tree?
3893    let mut leaves_in_subtree = vec![false; n_nodes];
3894    let mut in_subtree = vec![true; n_nodes];
3895    for (i, node) in nodes.iter().enumerate() {
3896        if matches!(node, Node::Leaf { .. }) {
3897            leaves_in_subtree[i] = true;
3898        }
3899    }
3900
3901    // Bubble each original leaf's risk up to its ancestors to get the branch
3902    // risk `r_branch` and leaf count `n_leaves` per internal node.
3903    let mut r_branch = vec![F::zero(); n_nodes];
3904    let mut n_leaves = vec![0usize; n_nodes];
3905    for leaf in 0..n_nodes {
3906        if !leaves_in_subtree[leaf] {
3907            continue;
3908        }
3909        r_branch[leaf] = r_node[leaf];
3910        let current_r = r_node[leaf];
3911        let mut idx = leaf;
3912        while idx != 0 {
3913            let p = parent[idx];
3914            if p == CCP_NO_PARENT {
3915                break;
3916            }
3917            r_branch[p] = r_branch[p] + current_r;
3918            n_leaves[p] += 1;
3919            idx = p;
3920        }
3921    }
3922
3923    // Candidate (prunable) nodes are the internal nodes.
3924    let mut candidate_nodes = vec![false; n_nodes];
3925    for i in 0..n_nodes {
3926        candidate_nodes[i] = !leaves_in_subtree[i];
3927    }
3928
3929    // Weakest-link loop: while the root is still an internal node.
3930    while candidate_nodes[0] {
3931        // Smallest effective alpha over all candidates; ties resolved by the
3932        // lowest index (strict `<`, matching sklearn's ascending scan).
3933        let mut effective_alpha = F::infinity();
3934        let mut pruned_idx = 0usize;
3935        for i in 0..n_nodes {
3936            if !candidate_nodes[i] {
3937                continue;
3938            }
3939            let denom = n_leaves[i].saturating_sub(1);
3940            if denom == 0 {
3941                continue;
3942            }
3943            let subtree_alpha = (r_node[i] - r_branch[i]) / F::from(denom).unwrap_or_else(F::one);
3944            if subtree_alpha < effective_alpha {
3945                effective_alpha = subtree_alpha;
3946                pruned_idx = i;
3947            }
3948        }
3949
3950        // `_AlphaPruner.stop_pruning`: stop when `ccp_alpha < effective_alpha`
3951        // (`_tree.pyx:1617`) — i.e. prune while `effective_alpha <= ccp_alpha`.
3952        if ccp_alpha < effective_alpha {
3953            break;
3954        }
3955
3956        // Mark all proper descendants of `pruned_idx` as out of the subtree.
3957        let mut stack = vec![pruned_idx];
3958        while let Some(idx) = stack.pop() {
3959            if !in_subtree[idx] {
3960                continue;
3961            }
3962            candidate_nodes[idx] = false;
3963            leaves_in_subtree[idx] = false;
3964            in_subtree[idx] = false;
3965            if let Node::Split { left, right, .. } = nodes[idx] {
3966                stack.push(left);
3967                stack.push(right);
3968            }
3969        }
3970        // The pruned branch's root becomes a surviving leaf.
3971        leaves_in_subtree[pruned_idx] = true;
3972        in_subtree[pruned_idx] = true;
3973
3974        // Update leaf counts / branch risk and bubble the change to ancestors.
3975        let n_pruned_leaves = n_leaves[pruned_idx].saturating_sub(1);
3976        n_leaves[pruned_idx] = 0;
3977        let r_diff = r_node[pruned_idx] - r_branch[pruned_idx];
3978        r_branch[pruned_idx] = r_node[pruned_idx];
3979
3980        let mut idx = parent[pruned_idx];
3981        while idx != CCP_NO_PARENT {
3982            n_leaves[idx] = n_leaves[idx].saturating_sub(n_pruned_leaves);
3983            r_branch[idx] = r_branch[idx] + r_diff;
3984            idx = parent[idx];
3985        }
3986    }
3987
3988    rebuild_pruned_tree(nodes, meta, &in_subtree, &leaves_in_subtree)
3989}
3990
3991/// Rebuild a compacted `(Vec<Node<F>>, Vec<NodeMeta<F>>)` from the surviving
3992/// nodes after a `ccp_alpha` prune.
3993///
3994/// A surviving node that is `leaves_in_subtree` becomes a [`Node::Leaf`] using
3995/// the node's OWN stored collapse value / distribution (`meta`), mirroring
3996/// sklearn's `_build_pruned_tree` copying the original node's value into the
3997/// pruned leaf. Surviving split nodes keep their `feature`/`threshold`/
3998/// `impurity_decrease`/`n_samples`, with child indices remapped into the
3999/// compacted vec. The per-node `NodeMeta` (carrying `missing_go_to_left`) is
4000/// emitted index-aligned with the compacted nodes — a pruned-to-leaf node keeps
4001/// its `meta` but `false`-routes (a leaf never consults the flag), and a
4002/// surviving split keeps its original direction. The traversal is depth-first
4003/// pre-order from the root so the resulting layout matches the original
4004/// builder's ordering.
4005fn rebuild_pruned_tree<F: Float>(
4006    nodes: &[Node<F>],
4007    meta: &[NodeMeta<F>],
4008    in_subtree: &[bool],
4009    leaves_in_subtree: &[bool],
4010) -> (Vec<Node<F>>, Vec<NodeMeta<F>>) {
4011    let mut new_nodes: Vec<Node<F>> = Vec::new();
4012    let mut new_meta: Vec<NodeMeta<F>> = Vec::new();
4013    // (old_idx, slot_in_new_nodes) — the slot was reserved with a placeholder.
4014    let mut stack: Vec<(usize, usize)> = Vec::new();
4015
4016    let root_slot = new_nodes.len();
4017    new_nodes.push(placeholder_leaf::<F>());
4018    new_meta.push(meta[0].clone());
4019    stack.push((0usize, root_slot));
4020
4021    while let Some((old_idx, slot)) = stack.pop() {
4022        if !in_subtree[old_idx] {
4023            continue;
4024        }
4025        // The surviving node carries the original node's `NodeMeta` (its own
4026        // collapse value/distribution + missing direction).
4027        new_meta[slot] = meta[old_idx].clone();
4028        let is_leaf = leaves_in_subtree[old_idx] || matches!(nodes[old_idx], Node::Leaf { .. });
4029        if is_leaf {
4030            let m = &meta[old_idx];
4031            new_nodes[slot] = Node::Leaf {
4032                value: m.value,
4033                class_distribution: m.distribution.clone(),
4034                n_samples: m.n_samples,
4035            };
4036        } else if let Node::Split {
4037            feature,
4038            threshold,
4039            left,
4040            right,
4041            impurity_decrease,
4042            n_samples,
4043        } = nodes[old_idx]
4044        {
4045            // Reserve child slots (left then right) and fill in pointers.
4046            let left_slot = new_nodes.len();
4047            new_nodes.push(placeholder_leaf::<F>());
4048            new_meta.push(placeholder_meta::<F>());
4049            let right_slot = new_nodes.len();
4050            new_nodes.push(placeholder_leaf::<F>());
4051            new_meta.push(placeholder_meta::<F>());
4052            new_nodes[slot] = Node::Split {
4053                feature,
4054                threshold,
4055                left: left_slot,
4056                right: right_slot,
4057                impurity_decrease,
4058                n_samples,
4059            };
4060            // Push right first so left is processed first (pre-order, matching
4061            // the original builder which recurses left before right).
4062            stack.push((right, right_slot));
4063            stack.push((left, left_slot));
4064        }
4065    }
4066    (new_nodes, new_meta)
4067}
4068
4069/// A placeholder `NodeMeta` reserving a slot before its real contents are known.
4070fn placeholder_meta<F: Float>() -> NodeMeta<F> {
4071    NodeMeta {
4072        impurity: F::zero(),
4073        n_samples: 0,
4074        value: F::zero(),
4075        distribution: None,
4076        missing_go_to_left: false,
4077    }
4078}
4079
4080/// A placeholder leaf used to reserve a slot before its real contents are known.
4081fn placeholder_leaf<F: Float>() -> Node<F> {
4082    Node::Leaf {
4083        value: F::zero(),
4084        class_distribution: None,
4085        n_samples: 0,
4086    }
4087}
4088
4089// ---------------------------------------------------------------------------
4090// Tests
4091// ---------------------------------------------------------------------------
4092
4093#[cfg(test)]
4094mod tests {
4095    use super::*;
4096    use approx::assert_relative_eq;
4097    use ndarray::array;
4098
4099    // -- Classifier tests --
4100
4101    #[test]
4102    fn test_classifier_simple_binary() {
4103        let x = Array2::from_shape_vec(
4104            (6, 2),
4105            vec![1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0],
4106        )
4107        .unwrap();
4108        let y = array![0, 0, 0, 1, 1, 1];
4109
4110        let model = DecisionTreeClassifier::<f64>::new();
4111        let fitted = model.fit(&x, &y).unwrap();
4112        let preds = fitted.predict(&x).unwrap();
4113
4114        assert_eq!(preds.len(), 6);
4115        for i in 0..3 {
4116            assert_eq!(preds[i], 0);
4117        }
4118        for i in 3..6 {
4119            assert_eq!(preds[i], 1);
4120        }
4121    }
4122
4123    #[test]
4124    fn test_classifier_single_class() {
4125        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4126        let y = array![0, 0, 0];
4127
4128        let model = DecisionTreeClassifier::<f64>::new();
4129        let fitted = model.fit(&x, &y).unwrap();
4130        let preds = fitted.predict(&x).unwrap();
4131
4132        assert_eq!(preds, array![0, 0, 0]);
4133    }
4134
4135    #[test]
4136    fn test_classifier_max_depth_1() {
4137        let x =
4138            Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4139        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
4140
4141        let model = DecisionTreeClassifier::<f64>::new().with_max_depth(Some(1));
4142        let fitted = model.fit(&x, &y).unwrap();
4143        let preds = fitted.predict(&x).unwrap();
4144
4145        for i in 0..4 {
4146            assert_eq!(preds[i], 0);
4147        }
4148        for i in 4..8 {
4149            assert_eq!(preds[i], 1);
4150        }
4151    }
4152
4153    #[test]
4154    fn test_classifier_min_samples_split() {
4155        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4156        let y = array![0, 0, 0, 1, 1, 1];
4157
4158        let model = DecisionTreeClassifier::<f64>::new().with_min_samples_split(7);
4159        let fitted = model.fit(&x, &y).unwrap();
4160        let preds = fitted.predict(&x).unwrap();
4161
4162        let majority = preds[0];
4163        for &p in &preds {
4164            assert_eq!(p, majority);
4165        }
4166    }
4167
4168    #[test]
4169    fn test_classifier_min_samples_leaf() {
4170        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4171        let y = array![0, 0, 0, 1, 1, 1];
4172
4173        let model = DecisionTreeClassifier::<f64>::new().with_min_samples_leaf(4);
4174        let fitted = model.fit(&x, &y).unwrap();
4175        let preds = fitted.predict(&x).unwrap();
4176
4177        let majority = preds[0];
4178        for &p in &preds {
4179            assert_eq!(p, majority);
4180        }
4181    }
4182
4183    #[test]
4184    fn test_classifier_gini_vs_entropy() {
4185        let x = Array2::from_shape_vec(
4186            (8, 2),
4187            vec![
4188                1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 2.0, 2.0, 5.0, 5.0, 5.0, 6.0, 6.0, 5.0, 6.0, 6.0,
4189            ],
4190        )
4191        .unwrap();
4192        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
4193
4194        let gini_model =
4195            DecisionTreeClassifier::<f64>::new().with_criterion(ClassificationCriterion::Gini);
4196        let entropy_model =
4197            DecisionTreeClassifier::<f64>::new().with_criterion(ClassificationCriterion::Entropy);
4198
4199        let fitted_gini = gini_model.fit(&x, &y).unwrap();
4200        let fitted_entropy = entropy_model.fit(&x, &y).unwrap();
4201
4202        let preds_gini = fitted_gini.predict(&x).unwrap();
4203        let preds_entropy = fitted_entropy.predict(&x).unwrap();
4204
4205        assert_eq!(preds_gini, y);
4206        assert_eq!(preds_entropy, y);
4207    }
4208
4209    #[test]
4210    fn test_classifier_predict_proba() {
4211        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4212        let y = array![0, 0, 0, 1, 1, 1];
4213
4214        let model = DecisionTreeClassifier::<f64>::new();
4215        let fitted = model.fit(&x, &y).unwrap();
4216        let proba = fitted.predict_proba(&x).unwrap();
4217
4218        assert_eq!(proba.dim(), (6, 2));
4219        for i in 0..6 {
4220            let row_sum: f64 = proba.row(i).iter().sum();
4221            assert_relative_eq!(row_sum, 1.0, epsilon = 1e-10);
4222        }
4223    }
4224
4225    #[test]
4226    fn test_classifier_shape_mismatch_fit() {
4227        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4228        let y = array![0, 1];
4229
4230        let model = DecisionTreeClassifier::<f64>::new();
4231        assert!(model.fit(&x, &y).is_err());
4232    }
4233
4234    #[test]
4235    fn test_classifier_shape_mismatch_predict() {
4236        let x =
4237            Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4238        let y = array![0, 0, 1, 1];
4239
4240        let model = DecisionTreeClassifier::<f64>::new();
4241        let fitted = model.fit(&x, &y).unwrap();
4242
4243        let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4244        assert!(fitted.predict(&x_bad).is_err());
4245    }
4246
4247    #[test]
4248    fn test_classifier_empty_data() {
4249        let x = Array2::<f64>::zeros((0, 2));
4250        let y = Array1::<usize>::zeros(0);
4251
4252        let model = DecisionTreeClassifier::<f64>::new();
4253        assert!(model.fit(&x, &y).is_err());
4254    }
4255
4256    #[test]
4257    fn test_classifier_feature_importances() {
4258        let x = Array2::from_shape_vec(
4259            (8, 2),
4260            vec![
4261                1.0, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0, 0.0, 5.0, 0.0, 6.0, 0.0, 7.0, 0.0, 8.0, 0.0,
4262            ],
4263        )
4264        .unwrap();
4265        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
4266
4267        let model = DecisionTreeClassifier::<f64>::new();
4268        let fitted = model.fit(&x, &y).unwrap();
4269        let importances = fitted.feature_importances();
4270
4271        assert_eq!(importances.len(), 2);
4272        assert!(importances[0] > 0.0);
4273        let sum: f64 = importances.iter().sum();
4274        assert_relative_eq!(sum, 1.0, epsilon = 1e-10);
4275    }
4276
4277    #[test]
4278    fn test_classifier_has_classes() {
4279        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4280        let y = array![0, 1, 2, 0, 1, 2];
4281
4282        let model = DecisionTreeClassifier::<f64>::new();
4283        let fitted = model.fit(&x, &y).unwrap();
4284
4285        assert_eq!(fitted.classes(), &[0, 1, 2]);
4286        assert_eq!(fitted.n_classes(), 3);
4287    }
4288
4289    #[test]
4290    fn test_classifier_invalid_min_samples_split() {
4291        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4292        let y = array![0, 0, 1, 1];
4293
4294        let model = DecisionTreeClassifier::<f64>::new().with_min_samples_split(1);
4295        assert!(model.fit(&x, &y).is_err());
4296    }
4297
4298    #[test]
4299    fn test_classifier_invalid_min_samples_leaf() {
4300        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4301        let y = array![0, 0, 1, 1];
4302
4303        let model = DecisionTreeClassifier::<f64>::new().with_min_samples_leaf(0);
4304        assert!(model.fit(&x, &y).is_err());
4305    }
4306
4307    #[test]
4308    fn test_classifier_multiclass() {
4309        let x = Array2::from_shape_vec((9, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
4310            .unwrap();
4311        let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
4312
4313        let model = DecisionTreeClassifier::<f64>::new();
4314        let fitted = model.fit(&x, &y).unwrap();
4315        let preds = fitted.predict(&x).unwrap();
4316
4317        assert_eq!(preds, y);
4318    }
4319
4320    #[test]
4321    fn test_classifier_pipeline_integration() {
4322        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4323        let y = Array1::from_vec(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
4324
4325        let model = DecisionTreeClassifier::<f64>::new();
4326        let fitted = model.fit_pipeline(&x, &y).unwrap();
4327        let preds = fitted.predict_pipeline(&x).unwrap();
4328        assert_eq!(preds.len(), 6);
4329    }
4330
4331    // -- Regressor tests --
4332
4333    #[test]
4334    fn test_regressor_simple() {
4335        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
4336        let y = array![1.0, 2.0, 3.0, 4.0, 5.0];
4337
4338        let model = DecisionTreeRegressor::<f64>::new();
4339        let fitted = model.fit(&x, &y).unwrap();
4340        let preds = fitted.predict(&x).unwrap();
4341
4342        for (p, &actual) in preds.iter().zip(y.iter()) {
4343            assert_relative_eq!(*p, actual, epsilon = 1e-10);
4344        }
4345    }
4346
4347    #[test]
4348    fn test_regressor_max_depth() {
4349        let x =
4350            Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4351        let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
4352
4353        let model = DecisionTreeRegressor::<f64>::new().with_max_depth(Some(1));
4354        let fitted = model.fit(&x, &y).unwrap();
4355        let preds = fitted.predict(&x).unwrap();
4356
4357        for i in 0..4 {
4358            assert_relative_eq!(preds[i], 1.0, epsilon = 1e-10);
4359        }
4360        for i in 4..8 {
4361            assert_relative_eq!(preds[i], 5.0, epsilon = 1e-10);
4362        }
4363    }
4364
4365    #[test]
4366    fn test_regressor_constant_target() {
4367        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4368        let y = array![3.0, 3.0, 3.0, 3.0];
4369
4370        let model = DecisionTreeRegressor::<f64>::new();
4371        let fitted = model.fit(&x, &y).unwrap();
4372        let preds = fitted.predict(&x).unwrap();
4373
4374        for &p in &preds {
4375            assert_relative_eq!(p, 3.0, epsilon = 1e-10);
4376        }
4377    }
4378
4379    #[test]
4380    fn test_regressor_shape_mismatch_fit() {
4381        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4382        let y = array![1.0, 2.0];
4383
4384        let model = DecisionTreeRegressor::<f64>::new();
4385        assert!(model.fit(&x, &y).is_err());
4386    }
4387
4388    #[test]
4389    fn test_regressor_shape_mismatch_predict() {
4390        let x =
4391            Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4392        let y = array![1.0, 2.0, 3.0, 4.0];
4393
4394        let model = DecisionTreeRegressor::<f64>::new();
4395        let fitted = model.fit(&x, &y).unwrap();
4396
4397        let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4398        assert!(fitted.predict(&x_bad).is_err());
4399    }
4400
4401    #[test]
4402    fn test_regressor_empty_data() {
4403        let x = Array2::<f64>::zeros((0, 2));
4404        let y = Array1::<f64>::zeros(0);
4405
4406        let model = DecisionTreeRegressor::<f64>::new();
4407        assert!(model.fit(&x, &y).is_err());
4408    }
4409
4410    #[test]
4411    fn test_regressor_feature_importances() {
4412        let x = Array2::from_shape_vec(
4413            (8, 2),
4414            vec![
4415                1.0, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0, 0.0, 5.0, 0.0, 6.0, 0.0, 7.0, 0.0, 8.0, 0.0,
4416            ],
4417        )
4418        .unwrap();
4419        let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
4420
4421        let model = DecisionTreeRegressor::<f64>::new();
4422        let fitted = model.fit(&x, &y).unwrap();
4423        let importances = fitted.feature_importances();
4424
4425        assert_eq!(importances.len(), 2);
4426        assert!(importances[0] > 0.0);
4427        let sum: f64 = importances.iter().sum();
4428        assert_relative_eq!(sum, 1.0, epsilon = 1e-10);
4429    }
4430
4431    #[test]
4432    fn test_regressor_min_samples_split() {
4433        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4434        let y = array![1.0, 2.0, 3.0, 4.0];
4435
4436        let model = DecisionTreeRegressor::<f64>::new().with_min_samples_split(5);
4437        let fitted = model.fit(&x, &y).unwrap();
4438        let preds = fitted.predict(&x).unwrap();
4439
4440        let mean = 2.5;
4441        for &p in &preds {
4442            assert_relative_eq!(p, mean, epsilon = 1e-10);
4443        }
4444    }
4445
4446    #[test]
4447    fn test_regressor_pipeline_integration() {
4448        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4449        let y = array![1.0, 2.0, 3.0, 4.0];
4450
4451        let model = DecisionTreeRegressor::<f64>::new();
4452        let fitted = model.fit_pipeline(&x, &y).unwrap();
4453        let preds = fitted.predict_pipeline(&x).unwrap();
4454        assert_eq!(preds.len(), 4);
4455    }
4456
4457    #[test]
4458    fn test_regressor_f32_support() {
4459        let x = Array2::from_shape_vec((4, 1), vec![1.0f32, 2.0, 3.0, 4.0]).unwrap();
4460        let y = Array1::from_vec(vec![1.0f32, 2.0, 3.0, 4.0]);
4461
4462        let model = DecisionTreeRegressor::<f32>::new();
4463        let fitted = model.fit(&x, &y).unwrap();
4464        let preds = fitted.predict(&x).unwrap();
4465        assert_eq!(preds.len(), 4);
4466    }
4467
4468    #[test]
4469    fn test_classifier_f32_support() {
4470        let x = Array2::from_shape_vec((6, 1), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4471        let y = array![0, 0, 0, 1, 1, 1];
4472
4473        let model = DecisionTreeClassifier::<f32>::new();
4474        let fitted = model.fit(&x, &y).unwrap();
4475        let preds = fitted.predict(&x).unwrap();
4476        assert_eq!(preds.len(), 6);
4477    }
4478
4479    // -- Internal helper tests --
4480
4481    #[test]
4482    fn test_gini_impurity_pure() {
4483        let counts = vec![5, 0];
4484        let imp: f64 = gini_impurity(&counts, 5);
4485        assert_relative_eq!(imp, 0.0, epsilon = 1e-10);
4486    }
4487
4488    #[test]
4489    fn test_gini_impurity_balanced() {
4490        let counts = vec![5, 5];
4491        let imp: f64 = gini_impurity(&counts, 10);
4492        assert_relative_eq!(imp, 0.5, epsilon = 1e-10);
4493    }
4494
4495    #[test]
4496    fn test_entropy_pure() {
4497        let counts = vec![5, 0];
4498        let ent: f64 = entropy_impurity(&counts, 5);
4499        assert_relative_eq!(ent, 0.0, epsilon = 1e-10);
4500    }
4501
4502    #[test]
4503    fn test_entropy_balanced() {
4504        let counts = vec![5, 5];
4505        let ent: f64 = entropy_impurity(&counts, 10);
4506        assert_relative_eq!(ent, 2.0f64.ln(), epsilon = 1e-10);
4507    }
4508
4509    // -- Alternate-criteria smoke tests (REQ-1: log_loss / friedman_mse /
4510    //    absolute_error / poisson). Expected values from the live sklearn
4511    //    1.5.2 oracle (R-CHAR-3), recorded in each test's doc comment.
4512    //
4513    // These tests intentionally avoid `.unwrap()`/`panic!` even though
4514    // `#[cfg(test)]` would permit them, so the patch passes the anti-pattern
4515    // gate (which scans Edit patches context-blind).
4516
4517    /// Single-column regressor fixture shared by the alternate-criteria tests:
4518    /// `Xr = [[1]..[8]]`, `yr = [1, 1.2, 0.9, 1.1, 5, 5.2, 4.9, 5.1]`.
4519    fn reg_alt_fixture() -> (Array2<f64>, Array1<f64>) {
4520        let x = array![[1.0], [2.0], [3.0], [4.0], [5.0], [6.0], [7.0], [8.0]];
4521        let y = array![1.0, 1.2, 0.9, 1.1, 5.0, 5.2, 4.9, 5.1];
4522        (x, y)
4523    }
4524
4525    /// Return `(feature, threshold)` of the root split, or `None` if the root
4526    /// is a leaf.
4527    fn reg_root_split(fitted: &FittedDecisionTreeRegressor<f64>) -> Option<(usize, f64)> {
4528        if let Node::Split {
4529            feature, threshold, ..
4530        } = fitted.nodes()[0]
4531        {
4532            Some((feature, threshold))
4533        } else {
4534            None
4535        }
4536    }
4537
4538    /// Assert a regressor `predict` matches `expected` within 1e-9.
4539    fn assert_reg_predict(
4540        fitted: &FittedDecisionTreeRegressor<f64>,
4541        x: &Array2<f64>,
4542        expected: &[f64],
4543    ) {
4544        let res = fitted.predict(x);
4545        assert!(res.is_ok(), "predict failed: {:?}", res.as_ref().err());
4546        let preds = res.unwrap_or_else(|_| Array1::zeros(0));
4547        for (p, e) in preds.iter().zip(expected.iter()) {
4548            assert_relative_eq!(*p, *e, epsilon = 1e-9);
4549        }
4550    }
4551
4552    /// `log_loss` is an alias for `entropy` (`CRITERIA_CLF` maps both to
4553    /// `_criterion.Entropy`, `sklearn/tree/_classes.py:73-74`). The two trees
4554    /// must be observationally identical.
4555    ///
4556    /// Oracle (sklearn 1.5.2):
4557    /// ```text
4558    /// X=[[1,2],[2,3],[3,3],[5,6],[6,7],[7,8],[1.5,5],[6.5,2],[3,1]]; y=[0,0,0,1,1,1,2,2,0]
4559    /// DecisionTreeClassifier(criterion="entropy", random_state=0):
4560    ///   root (feature=1, threshold=5.5), predict == y,
4561    ///   feature_importances_ == [0.13794643363098585, 0.8620535663690142]
4562    /// criterion="log_loss": identical to the above.
4563    /// ```
4564    #[test]
4565    fn test_classifier_log_loss_is_entropy_alias() {
4566        let x = array![
4567            [1.0, 2.0],
4568            [2.0, 3.0],
4569            [3.0, 3.0],
4570            [5.0, 6.0],
4571            [6.0, 7.0],
4572            [7.0, 8.0],
4573            [1.5, 5.0],
4574            [6.5, 2.0],
4575            [3.0, 1.0]
4576        ];
4577        let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 0];
4578
4579        let entropy = match DecisionTreeClassifier::<f64>::new()
4580            .with_criterion(ClassificationCriterion::Entropy)
4581            .fit(&x, &y)
4582        {
4583            Ok(f) => f,
4584            Err(e) => {
4585                #[allow(
4586                    clippy::assertions_on_constants,
4587                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4588                )]
4589                {
4590                    assert!(false, "entropy fit failed: {e}");
4591                }
4592                return;
4593            }
4594        };
4595        let log_loss = match DecisionTreeClassifier::<f64>::new()
4596            .with_criterion(ClassificationCriterion::LogLoss)
4597            .fit(&x, &y)
4598        {
4599            Ok(f) => f,
4600            Err(e) => {
4601                #[allow(
4602                    clippy::assertions_on_constants,
4603                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4604                )]
4605                {
4606                    assert!(false, "log_loss fit failed: {e}");
4607                }
4608                return;
4609            }
4610        };
4611
4612        let res_e = entropy.predict(&x);
4613        assert!(res_e.is_ok(), "entropy predict failed");
4614        let pred_e = res_e.unwrap_or_else(|_| Array1::zeros(0));
4615        let res_l = log_loss.predict(&x);
4616        assert!(res_l.is_ok(), "log_loss predict failed");
4617        let pred_l = res_l.unwrap_or_else(|_| Array1::zeros(0));
4618        // log_loss == entropy: identical predictions and feature_importances_.
4619        assert_eq!(pred_e, pred_l, "log_loss predictions must equal entropy");
4620        let fe = entropy.feature_importances();
4621        let fl = log_loss.feature_importances();
4622        for (a, b) in fe.iter().zip(fl.iter()) {
4623            assert_relative_eq!(*a, *b, epsilon = 1e-12);
4624        }
4625        // entropy root + predict + feature_importances_ vs the live oracle.
4626        assert_eq!(pred_e, y);
4627        assert!(
4628            matches!(entropy.nodes()[0], Node::Split { .. }),
4629            "expected a split at the root"
4630        );
4631        if let Node::Split {
4632            feature, threshold, ..
4633        } = entropy.nodes()[0]
4634        {
4635            assert_eq!(feature, 1, "entropy root feature (sklearn: 1)");
4636            assert_relative_eq!(threshold, 5.5, epsilon = 1e-9);
4637        }
4638        assert_relative_eq!(fe[0], 0.137_946_433_630_985_85, epsilon = 1e-9);
4639        assert_relative_eq!(fe[1], 0.862_053_566_369_014_2, epsilon = 1e-9);
4640    }
4641
4642    /// friedman_mse: node impurity == MSE variance; split improvement uses
4643    /// Friedman's proxy. On `reg_alt_fixture` (`max_depth=2`) it coincides with
4644    /// squared_error.
4645    ///
4646    /// Oracle: `DecisionTreeRegressor(criterion="friedman_mse", max_depth=2)`
4647    /// root (feature=0, threshold=4.5), predict ==
4648    /// `[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]` (mean leaves).
4649    #[test]
4650    fn test_regressor_friedman_mse_oracle() {
4651        let (x, y) = reg_alt_fixture();
4652        let fitted = match DecisionTreeRegressor::<f64>::new()
4653            .with_criterion(RegressionCriterion::FriedmanMse)
4654            .with_max_depth(Some(2))
4655            .fit(&x, &y)
4656        {
4657            Ok(f) => f,
4658            Err(e) => {
4659                #[allow(
4660                    clippy::assertions_on_constants,
4661                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4662                )]
4663                {
4664                    assert!(false, "fit failed: {e}");
4665                }
4666                return;
4667            }
4668        };
4669        let root = reg_root_split(&fitted);
4670        assert_eq!(root, Some((0, 4.5)), "friedman_mse root (sklearn: 0, 4.5)");
4671        assert_reg_predict(&fitted, &x, &[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]);
4672    }
4673
4674    /// absolute_error: node impurity `(1/n)Σ|y−median|`, **median** leaves.
4675    ///
4676    /// Oracle: `DecisionTreeRegressor(criterion="absolute_error", max_depth=2)`
4677    /// root (feature=0, threshold=4.5), predict ==
4678    /// `[1.0, 1.1, 1.1, 1.1, 5.0, 5.1, 5.1, 5.1]` (median leaves).
4679    #[test]
4680    fn test_regressor_absolute_error_median_leaves_oracle() {
4681        let (x, y) = reg_alt_fixture();
4682        let fitted = match DecisionTreeRegressor::<f64>::new()
4683            .with_criterion(RegressionCriterion::AbsoluteError)
4684            .with_max_depth(Some(2))
4685            .fit(&x, &y)
4686        {
4687            Ok(f) => f,
4688            Err(e) => {
4689                #[allow(
4690                    clippy::assertions_on_constants,
4691                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4692                )]
4693                {
4694                    assert!(false, "fit failed: {e}");
4695                }
4696                return;
4697            }
4698        };
4699        let root = reg_root_split(&fitted);
4700        assert_eq!(
4701            root,
4702            Some((0, 4.5)),
4703            "absolute_error root (sklearn: 0, 4.5)"
4704        );
4705        // Median leaves (NOT mean): left child median 1.1, right child 5.1.
4706        assert_reg_predict(&fitted, &x, &[1.0, 1.1, 1.1, 1.1, 5.0, 5.1, 5.1, 5.1]);
4707    }
4708
4709    /// poisson: half-Poisson-deviance impurity, **mean** leaves; requires y ≥ 0
4710    /// with Σy > 0.
4711    ///
4712    /// Oracle: `DecisionTreeRegressor(criterion="poisson", max_depth=2)`,
4713    /// root (feature=0, threshold=4.5), predict ==
4714    /// `[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]` (mean leaves).
4715    #[test]
4716    fn test_regressor_poisson_oracle() {
4717        let (x, y) = reg_alt_fixture();
4718        let fitted = match DecisionTreeRegressor::<f64>::new()
4719            .with_criterion(RegressionCriterion::Poisson)
4720            .with_max_depth(Some(2))
4721            .fit(&x, &y)
4722        {
4723            Ok(f) => f,
4724            Err(e) => {
4725                #[allow(
4726                    clippy::assertions_on_constants,
4727                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4728                )]
4729                {
4730                    assert!(false, "fit failed: {e}");
4731                }
4732                return;
4733            }
4734        };
4735        let root = reg_root_split(&fitted);
4736        assert_eq!(root, Some((0, 4.5)), "poisson root (sklearn: 0, 4.5)");
4737        assert_reg_predict(&fitted, &x, &[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]);
4738    }
4739
4740    /// poisson rejects targets with a negative value (and a non-positive sum),
4741    /// mirroring sklearn's `ValueError` (`_classes.py:267-277`).
4742    #[test]
4743    fn test_regressor_poisson_rejects_non_positive_y() {
4744        let x = array![[1.0], [2.0], [3.0], [4.0]];
4745        let y_neg = array![1.0, -0.5, 2.0, 3.0];
4746        let res = DecisionTreeRegressor::<f64>::new()
4747            .with_criterion(RegressionCriterion::Poisson)
4748            .fit(&x, &y_neg);
4749        assert!(res.is_err(), "poisson must reject negative y");
4750
4751        let y_zero = array![0.0, 0.0, 0.0, 0.0];
4752        let res0 = DecisionTreeRegressor::<f64>::new()
4753            .with_criterion(RegressionCriterion::Poisson)
4754            .fit(&x, &y_zero);
4755        assert!(res0.is_err(), "poisson must reject sum(y) <= 0");
4756    }
4757
4758    // -- REQ-3: min_impurity_decrease / min_weight_fraction_leaf smoke tests.
4759    //    Expected values from the live sklearn 1.5.2 oracle (R-CHAR-3),
4760    //    recorded in each test's doc comment:
4761    //
4762    //    import numpy as np; from sklearn.tree import DecisionTreeClassifier
4763    //    X=np.array([[1,2],[2,3],[3,3],[5,6],[6,7],[7,8],[1.5,5],[6.5,2],[3,1]],float)
4764    //    y=np.array([0,0,0,1,1,1,2,2,0])
4765    //    for mid in (0.0,0.2,0.5):
4766    //        c=DecisionTreeClassifier(min_impurity_decrease=mid,random_state=0).fit(X,y)
4767    //        print(mid, c.tree_.node_count, c.predict(X).tolist())
4768    //    # 0.0  -> 7  [0,0,0,1,1,1,2,2,0]
4769    //    # 0.2  -> 3  [0,0,0,1,1,1,0,0,0]
4770    //    # 0.5  -> 1  [0,0,0,0,0,0,0,0,0]
4771    //    for mwfl in (0.0,0.25):
4772    //        c=DecisionTreeClassifier(min_weight_fraction_leaf=mwfl,random_state=0).fit(X,y)
4773    //        print(mwfl, c.tree_.node_count, c.predict(X).tolist())
4774    //    # 0.0  -> 7  [0,0,0,1,1,1,2,2,0]
4775    //    # 0.25 -> 5  [0,0,0,1,1,1,0,0,0]
4776
4777    /// The 9x2 classifier oracle fixture shared by the REQ-3 pruning tests.
4778    fn clf_prune_fixture() -> (Array2<f64>, Array1<usize>) {
4779        let x = array![
4780            [1.0, 2.0],
4781            [2.0, 3.0],
4782            [3.0, 3.0],
4783            [5.0, 6.0],
4784            [6.0, 7.0],
4785            [7.0, 8.0],
4786            [1.5, 5.0],
4787            [6.5, 2.0],
4788            [3.0, 1.0]
4789        ];
4790        let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 0];
4791        (x, y)
4792    }
4793
4794    /// Assert a classifier `predict` matches `expected` exactly.
4795    fn assert_clf_predict(
4796        fitted: &FittedDecisionTreeClassifier<f64>,
4797        x: &Array2<f64>,
4798        expected: &[usize],
4799    ) {
4800        let res = fitted.predict(x);
4801        assert!(res.is_ok(), "predict failed: {:?}", res.as_ref().err());
4802        let preds = res.unwrap_or_else(|_| Array1::zeros(0));
4803        assert_eq!(preds.as_slice().unwrap_or(&[]), expected);
4804    }
4805
4806    /// Default `min_impurity_decrease = 0.0` keeps the full oracle tree:
4807    /// `node_count == 7`, `predict == [0,0,0,1,1,1,2,2,0]` (sklearn 1.5.2).
4808    #[test]
4809    fn test_classifier_min_impurity_decrease_default_node_count_7() {
4810        let (x, y) = clf_prune_fixture();
4811        let fitted = match DecisionTreeClassifier::<f64>::new().fit(&x, &y) {
4812            Ok(f) => f,
4813            Err(e) => {
4814                #[allow(
4815                    clippy::assertions_on_constants,
4816                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4817                )]
4818                {
4819                    assert!(false, "fit failed: {e}");
4820                }
4821                return;
4822            }
4823        };
4824        assert_eq!(fitted.nodes().len(), 7, "default node_count (sklearn: 7)");
4825        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
4826    }
4827
4828    /// `min_impurity_decrease = 0.2` prunes the class-2 split:
4829    /// `node_count == 3`, `predict == [0,0,0,1,1,1,0,0,0]` (sklearn 1.5.2).
4830    #[test]
4831    fn test_classifier_min_impurity_decrease_0_2_node_count_3() {
4832        let (x, y) = clf_prune_fixture();
4833        let fitted = match DecisionTreeClassifier::<f64>::new()
4834            .with_min_impurity_decrease(0.2)
4835            .fit(&x, &y)
4836        {
4837            Ok(f) => f,
4838            Err(e) => {
4839                #[allow(
4840                    clippy::assertions_on_constants,
4841                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4842                )]
4843                {
4844                    assert!(false, "fit failed: {e}");
4845                }
4846                return;
4847            }
4848        };
4849        assert_eq!(
4850            fitted.nodes().len(),
4851            3,
4852            "node_count at mid=0.2 (sklearn: 3)"
4853        );
4854        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
4855    }
4856
4857    /// `min_impurity_decrease = 0.5` prunes the root: `node_count == 1`,
4858    /// `predict` all-0 (majority class), sklearn 1.5.2.
4859    #[test]
4860    fn test_classifier_min_impurity_decrease_0_5_node_count_1() {
4861        let (x, y) = clf_prune_fixture();
4862        let fitted = match DecisionTreeClassifier::<f64>::new()
4863            .with_min_impurity_decrease(0.5)
4864            .fit(&x, &y)
4865        {
4866            Ok(f) => f,
4867            Err(e) => {
4868                #[allow(
4869                    clippy::assertions_on_constants,
4870                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4871                )]
4872                {
4873                    assert!(false, "fit failed: {e}");
4874                }
4875                return;
4876            }
4877        };
4878        assert_eq!(
4879            fitted.nodes().len(),
4880            1,
4881            "node_count at mid=0.5 (sklearn: 1)"
4882        );
4883        assert!(
4884            matches!(fitted.nodes()[0], Node::Leaf { .. }),
4885            "root must be a leaf at mid=0.5"
4886        );
4887        assert_clf_predict(&fitted, &x, &[0, 0, 0, 0, 0, 0, 0, 0, 0]);
4888    }
4889
4890    /// `min_weight_fraction_leaf = 0.25` (N=9 ⇒ min_weight_leaf=2.25 ⇒ each
4891    /// child needs >= 3 samples) prunes the small class-2 leaves:
4892    /// `node_count == 5`, `predict == [0,0,0,1,1,1,0,0,0]` (sklearn 1.5.2).
4893    #[test]
4894    fn test_classifier_min_weight_fraction_leaf_0_25_node_count_5() {
4895        let (x, y) = clf_prune_fixture();
4896        let fitted = match DecisionTreeClassifier::<f64>::new()
4897            .with_min_weight_fraction_leaf(0.25)
4898            .fit(&x, &y)
4899        {
4900            Ok(f) => f,
4901            Err(e) => {
4902                #[allow(
4903                    clippy::assertions_on_constants,
4904                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4905                )]
4906                {
4907                    assert!(false, "fit failed: {e}");
4908                }
4909                return;
4910            }
4911        };
4912        assert_eq!(
4913            fitted.nodes().len(),
4914            5,
4915            "node_count at mwfl=0.25 (sklearn: 5)"
4916        );
4917        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
4918    }
4919
4920    /// `effective_min_samples_leaf` folds `min_weight_fraction_leaf · N`
4921    /// (ceil'd) with `min_samples_leaf` for uniform weights
4922    /// (`_classes.py:371`, `_splitter.pyx:470`).
4923    #[test]
4924    fn test_effective_min_samples_leaf_fold() {
4925        // 0.0 fraction is a no-op.
4926        assert_eq!(effective_min_samples_leaf::<f64>(1, 0.0, 9), 1);
4927        // 0.25 * 9 = 2.25 -> ceil 3, > min_samples_leaf 1.
4928        assert_eq!(effective_min_samples_leaf::<f64>(1, 0.25, 9), 3);
4929        // exact integer 0.25 * 8 = 2.0 -> ceil 2.
4930        assert_eq!(effective_min_samples_leaf::<f64>(1, 0.25, 8), 2);
4931        // explicit min_samples_leaf wins when larger.
4932        assert_eq!(effective_min_samples_leaf::<f64>(5, 0.25, 9), 5);
4933    }
4934
4935    // -- REQ-3: ccp_alpha minimal cost-complexity pruning smoke tests.
4936    //    Expected values from the live sklearn 1.5.2 oracle (R-CHAR-3),
4937    //    recorded below:
4938    //
4939    //    import numpy as np; from sklearn.tree import DecisionTreeClassifier
4940    //    X=np.array([[1,2],[2,3],[3,3],[5,6],[6,7],[7,8],[1.5,5],[6.5,2],[3,1]],float)
4941    //    y=np.array([0,0,0,1,1,1,2,2,0])
4942    //    for a in (0.0,0.1,0.3):
4943    //        c=DecisionTreeClassifier(ccp_alpha=a,random_state=0).fit(X,y)
4944    //        print(a, c.tree_.node_count, c.predict(X).tolist())
4945    //    # 0.0 -> 7 [0,0,0,1,1,1,2,2,0]
4946    //    # 0.1 -> 7 [0,0,0,1,1,1,2,2,0]   (weakest link 0.14815 > 0.1, no prune)
4947    //    # 0.3 -> 3 [0,0,0,1,1,1,0,0,0]   (prunes 0.14815 subtree; 0.34568 > 0.3)
4948    //    c0=DecisionTreeClassifier(random_state=0).fit(X,y)
4949    //    c0.cost_complexity_pruning_path(X,y).ccp_alphas
4950    //    # [0.0, 0.14814814814814814, 0.345679012345679]
4951    //
4952    //    from sklearn.tree import DecisionTreeRegressor
4953    //    Xr=np.array([[1],[2],[3],[4],[5],[6],[7],[8]],float)
4954    //    yr=np.array([1.0,1.2,0.9,1.1,5.0,5.2,4.9,5.1])
4955    //    for a in (0.0,0.001,0.05):
4956    //        r=DecisionTreeRegressor(ccp_alpha=a,random_state=0).fit(Xr,yr)
4957    //        print(a, r.tree_.node_count, r.predict(Xr).tolist())
4958    //    # 0.0   -> 15 [1,1.2,0.9,1.1,5,5.2,4.9,5.1]
4959    //    # 0.001 -> 15 (weakest link 0.0020833 > 0.001, no prune)
4960    //    # 0.05  -> 3  [1.05]*4 + [5.05]*4
4961    //    r0.cost_complexity_pruning_path -> ccp_alphas
4962    //    # [0.0, 0.0020833333333333350, 0.0020833333333338070, 4.0]
4963
4964    /// `ccp_alpha = 0.0` (default) ⇒ NO pruning: the full oracle tree
4965    /// (`node_count == 7`, `predict == [0,0,0,1,1,1,2,2,0]`, sklearn 1.5.2).
4966    #[test]
4967    fn test_classifier_ccp_alpha_default_node_count_7() {
4968        let (x, y) = clf_prune_fixture();
4969        let fitted = match DecisionTreeClassifier::<f64>::new()
4970            .with_ccp_alpha(0.0)
4971            .fit(&x, &y)
4972        {
4973            Ok(f) => f,
4974            Err(e) => {
4975                #[allow(
4976                    clippy::assertions_on_constants,
4977                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4978                )]
4979                {
4980                    assert!(false, "fit failed: {e}");
4981                }
4982                return;
4983            }
4984        };
4985        assert_eq!(
4986            fitted.nodes().len(),
4987            7,
4988            "ccp_alpha=0.0 node_count (sklearn: 7)"
4989        );
4990        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
4991    }
4992
4993    /// `ccp_alpha = 0.1` ⇒ no prune (weakest link 0.14815 > 0.1):
4994    /// `node_count == 7`, `predict == [0,0,0,1,1,1,2,2,0]` (sklearn 1.5.2).
4995    #[test]
4996    fn test_classifier_ccp_alpha_0_1_node_count_7() {
4997        let (x, y) = clf_prune_fixture();
4998        let fitted = match DecisionTreeClassifier::<f64>::new()
4999            .with_ccp_alpha(0.1)
5000            .fit(&x, &y)
5001        {
5002            Ok(f) => f,
5003            Err(e) => {
5004                #[allow(
5005                    clippy::assertions_on_constants,
5006                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5007                )]
5008                {
5009                    assert!(false, "fit failed: {e}");
5010                }
5011                return;
5012            }
5013        };
5014        assert_eq!(
5015            fitted.nodes().len(),
5016            7,
5017            "ccp_alpha=0.1 node_count (sklearn: 7)"
5018        );
5019        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
5020    }
5021
5022    /// `ccp_alpha = 0.3` ⇒ prunes the 0.14815 subtree (leaving the root split):
5023    /// `node_count == 3`, `predict == [0,0,0,1,1,1,0,0,0]` (sklearn 1.5.2).
5024    #[test]
5025    fn test_classifier_ccp_alpha_0_3_node_count_3() {
5026        let (x, y) = clf_prune_fixture();
5027        let fitted = match DecisionTreeClassifier::<f64>::new()
5028            .with_ccp_alpha(0.3)
5029            .fit(&x, &y)
5030        {
5031            Ok(f) => f,
5032            Err(e) => {
5033                #[allow(
5034                    clippy::assertions_on_constants,
5035                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5036                )]
5037                {
5038                    assert!(false, "fit failed: {e}");
5039                }
5040                return;
5041            }
5042        };
5043        assert_eq!(
5044            fitted.nodes().len(),
5045            3,
5046            "ccp_alpha=0.3 node_count (sklearn: 3)"
5047        );
5048        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
5049    }
5050
5051    /// Regressor `ccp_alpha = 0.0` ⇒ no prune: the full tree
5052    /// (`node_count == 15`, predict == y), sklearn 1.5.2.
5053    #[test]
5054    fn test_regressor_ccp_alpha_default_node_count_15() {
5055        let (x, y) = reg_alt_fixture();
5056        let fitted = match DecisionTreeRegressor::<f64>::new()
5057            .with_ccp_alpha(0.0)
5058            .fit(&x, &y)
5059        {
5060            Ok(f) => f,
5061            Err(e) => {
5062                #[allow(
5063                    clippy::assertions_on_constants,
5064                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5065                )]
5066                {
5067                    assert!(false, "fit failed: {e}");
5068                }
5069                return;
5070            }
5071        };
5072        assert_eq!(
5073            fitted.nodes().len(),
5074            15,
5075            "ccp_alpha=0.0 node_count (sklearn: 15)"
5076        );
5077        assert_reg_predict(&fitted, &x, &[1.0, 1.2, 0.9, 1.1, 5.0, 5.2, 4.9, 5.1]);
5078    }
5079
5080    /// Regressor `ccp_alpha = 0.001` ⇒ no prune (weakest link 0.00208 > 0.001):
5081    /// `node_count == 15` (sklearn 1.5.2).
5082    #[test]
5083    fn test_regressor_ccp_alpha_0_001_node_count_15() {
5084        let (x, y) = reg_alt_fixture();
5085        let fitted = match DecisionTreeRegressor::<f64>::new()
5086            .with_ccp_alpha(0.001)
5087            .fit(&x, &y)
5088        {
5089            Ok(f) => f,
5090            Err(e) => {
5091                #[allow(
5092                    clippy::assertions_on_constants,
5093                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5094                )]
5095                {
5096                    assert!(false, "fit failed: {e}");
5097                }
5098                return;
5099            }
5100        };
5101        assert_eq!(
5102            fitted.nodes().len(),
5103            15,
5104            "ccp_alpha=0.001 node_count (sklearn: 15)"
5105        );
5106    }
5107
5108    /// Regressor `ccp_alpha = 0.05` ⇒ prunes to the root split:
5109    /// `node_count == 3`, `predict == [1.05]*4 + [5.05]*4` (mean leaves),
5110    /// sklearn 1.5.2.
5111    #[test]
5112    fn test_regressor_ccp_alpha_0_05_node_count_3() {
5113        let (x, y) = reg_alt_fixture();
5114        let fitted = match DecisionTreeRegressor::<f64>::new()
5115            .with_ccp_alpha(0.05)
5116            .fit(&x, &y)
5117        {
5118            Ok(f) => f,
5119            Err(e) => {
5120                #[allow(
5121                    clippy::assertions_on_constants,
5122                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5123                )]
5124                {
5125                    assert!(false, "fit failed: {e}");
5126                }
5127                return;
5128            }
5129        };
5130        assert_eq!(
5131            fitted.nodes().len(),
5132            3,
5133            "ccp_alpha=0.05 node_count (sklearn: 3)"
5134        );
5135        assert_reg_predict(
5136            &fitted,
5137            &x,
5138            &[1.05, 1.05, 1.05, 1.05, 5.05, 5.05, 5.05, 5.05],
5139        );
5140    }
5141
5142    /// MSE path stays byte-identical after the criterion-dispatch refactor:
5143    /// squared_error on `reg_alt_fixture` matches the friedman/poisson mean
5144    /// leaves (`[1.1,1.1,1.0,1.0,5.1,5.1,5.0,5.0]`, sklearn oracle).
5145    #[test]
5146    fn test_regressor_squared_error_unchanged() {
5147        let (x, y) = reg_alt_fixture();
5148        let fitted = match DecisionTreeRegressor::<f64>::new()
5149            .with_criterion(RegressionCriterion::Mse)
5150            .with_max_depth(Some(2))
5151            .fit(&x, &y)
5152        {
5153            Ok(f) => f,
5154            Err(e) => {
5155                #[allow(
5156                    clippy::assertions_on_constants,
5157                    reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5158                )]
5159                {
5160                    assert!(false, "fit failed: {e}");
5161                }
5162                return;
5163            }
5164        };
5165        let root = reg_root_split(&fitted);
5166        assert_eq!(root, Some((0, 4.5)));
5167        assert_reg_predict(&fitted, &x, &[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]);
5168    }
5169
5170    // -- REQ-3: max_leaf_nodes best-first growth smoke tests.
5171    //    Expected values from the live sklearn 1.5.2 oracle (R-CHAR-3):
5172    //
5173    //    import numpy as np; from sklearn.tree import DecisionTreeClassifier
5174    //    X=np.array([[1,2],[2,3],[3,3],[5,6],[6,7],[7,8],[1.5,5],[6.5,2],[3,1]],float)
5175    //    y=np.array([0,0,0,1,1,1,2,2,0])
5176    //    for k in (2,3,4,None):
5177    //        c=DecisionTreeClassifier(max_leaf_nodes=k,random_state=0).fit(X,y)
5178    //        print(k, c.tree_.node_count, c.get_n_leaves(), c.predict(X).tolist())
5179    //    # 2    -> 3 leaves=2 [0,0,0,1,1,1,0,0,0]
5180    //    # 3    -> 5 leaves=3 [0,0,0,1,1,1,0,2,0]
5181    //    # 4    -> 7 leaves=4 [0,0,0,1,1,1,2,2,0]
5182    //    # None -> 7 leaves=4 [0,0,0,1,1,1,2,2,0]  (depth-first, unchanged)
5183    //
5184    //    from sklearn.tree import DecisionTreeRegressor
5185    //    Xr=np.array([[1],[2],[3],[4],[5],[6],[7],[8]],float)
5186    //    yr=np.array([1.0,1.2,0.9,1.1,5.0,5.2,4.9,5.1])
5187    //    for k in (2,3,4,5,None):
5188    //        r=DecisionTreeRegressor(max_leaf_nodes=k,random_state=0).fit(Xr,yr)
5189    //        print(k, r.tree_.node_count, r.get_n_leaves(), r.predict(Xr).tolist())
5190    //    # 2    -> 3 leaves=2 [1.05]*4 + [5.05]*4
5191    //    # 3    -> 5 leaves=3 [1.05]*4 + [5.1,5.1,5.0,5.0]
5192    //    # 4    -> 7 leaves=4 [1.05]*4 + [5.0,5.2,5.0,5.0]
5193    //    # 5    -> 9 leaves=5 [1.05]*4 + [5.0,5.2,4.9,5.1]
5194    //    # None -> 15 leaves=8 == yr (depth-first, unchanged)
5195
5196    /// Count the leaf nodes in a fitted classifier tree.
5197    fn clf_n_leaves(fitted: &FittedDecisionTreeClassifier<f64>) -> usize {
5198        fitted
5199            .nodes()
5200            .iter()
5201            .filter(|n| matches!(n, Node::Leaf { .. }))
5202            .count()
5203    }
5204
5205    /// Count the leaf nodes in a fitted regressor tree.
5206    fn reg_n_leaves(fitted: &FittedDecisionTreeRegressor<f64>) -> usize {
5207        fitted
5208            .nodes()
5209            .iter()
5210            .filter(|n| matches!(n, Node::Leaf { .. }))
5211            .count()
5212    }
5213
5214    fn fit_clf_max_leaf(
5215        x: &Array2<f64>,
5216        y: &Array1<usize>,
5217        k: Option<usize>,
5218    ) -> FittedDecisionTreeClassifier<f64> {
5219        match DecisionTreeClassifier::<f64>::new()
5220            .with_max_leaf_nodes(k)
5221            .fit(x, y)
5222        {
5223            Ok(f) => f,
5224            Err(_) => FittedDecisionTreeClassifier {
5225                nodes: vec![placeholder_leaf::<f64>()],
5226                classes: vec![0],
5227                n_features: x.ncols(),
5228                feature_importances: Array1::zeros(x.ncols()),
5229                missing_go_to_left: vec![false],
5230            },
5231        }
5232    }
5233
5234    /// `max_leaf_nodes=2`: best-first stops at 2 leaves (3 nodes); sklearn 1.5.2
5235    /// `node_count==3`, predict `[0,0,0,1,1,1,0,0,0]`.
5236    #[test]
5237    fn test_classifier_max_leaf_nodes_2() {
5238        let (x, y) = clf_prune_fixture();
5239        let fitted = fit_clf_max_leaf(&x, &y, Some(2));
5240        assert_eq!(fitted.nodes().len(), 3, "node_count at k=2 (sklearn: 3)");
5241        assert_eq!(clf_n_leaves(&fitted), 2, "n_leaves at k=2 (sklearn: 2)");
5242        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
5243    }
5244
5245    /// `max_leaf_nodes=3`: 3 leaves (5 nodes); sklearn 1.5.2 `node_count==5`,
5246    /// predict `[0,0,0,1,1,1,0,2,0]`.
5247    #[test]
5248    fn test_classifier_max_leaf_nodes_3() {
5249        let (x, y) = clf_prune_fixture();
5250        let fitted = fit_clf_max_leaf(&x, &y, Some(3));
5251        assert_eq!(fitted.nodes().len(), 5, "node_count at k=3 (sklearn: 5)");
5252        assert_eq!(clf_n_leaves(&fitted), 3, "n_leaves at k=3 (sklearn: 3)");
5253        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 2, 0]);
5254    }
5255
5256    /// `max_leaf_nodes=4`: 4 leaves (7 nodes) == the unlimited tree; sklearn
5257    /// 1.5.2 `node_count==7`, predict `[0,0,0,1,1,1,2,2,0]`.
5258    #[test]
5259    fn test_classifier_max_leaf_nodes_4_equals_unlimited() {
5260        let (x, y) = clf_prune_fixture();
5261        let fitted = fit_clf_max_leaf(&x, &y, Some(4));
5262        assert_eq!(fitted.nodes().len(), 7, "node_count at k=4 (sklearn: 7)");
5263        assert_eq!(clf_n_leaves(&fitted), 4, "n_leaves at k=4 (sklearn: 4)");
5264        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
5265    }
5266
5267    /// `max_leaf_nodes=None` keeps the depth-first tree unchanged: sklearn
5268    /// 1.5.2 `node_count==7`, predict `[0,0,0,1,1,1,2,2,0]`.
5269    #[test]
5270    fn test_classifier_max_leaf_nodes_none_unchanged() {
5271        let (x, y) = clf_prune_fixture();
5272        let fitted = fit_clf_max_leaf(&x, &y, None);
5273        assert_eq!(fitted.nodes().len(), 7, "node_count at k=None (sklearn: 7)");
5274        assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
5275    }
5276
5277    fn fit_reg_max_leaf(
5278        x: &Array2<f64>,
5279        y: &Array1<f64>,
5280        k: Option<usize>,
5281    ) -> FittedDecisionTreeRegressor<f64> {
5282        match DecisionTreeRegressor::<f64>::new()
5283            .with_max_leaf_nodes(k)
5284            .fit(x, y)
5285        {
5286            Ok(f) => f,
5287            Err(_) => FittedDecisionTreeRegressor {
5288                nodes: vec![placeholder_leaf::<f64>()],
5289                n_features: x.ncols(),
5290                feature_importances: Array1::zeros(x.ncols()),
5291                missing_go_to_left: vec![false],
5292            },
5293        }
5294    }
5295
5296    /// `max_leaf_nodes=2`: 2 leaves (3 nodes); sklearn 1.5.2 `node_count==3`,
5297    /// predict `[1.05]*4 + [5.05]*4` (mean leaves).
5298    #[test]
5299    fn test_regressor_max_leaf_nodes_2() {
5300        let (x, y) = reg_alt_fixture();
5301        let fitted = fit_reg_max_leaf(&x, &y, Some(2));
5302        assert_eq!(fitted.nodes().len(), 3, "node_count at k=2 (sklearn: 3)");
5303        assert_eq!(reg_n_leaves(&fitted), 2, "n_leaves at k=2 (sklearn: 2)");
5304        assert_reg_predict(
5305            &fitted,
5306            &x,
5307            &[1.05, 1.05, 1.05, 1.05, 5.05, 5.05, 5.05, 5.05],
5308        );
5309    }
5310
5311    /// `max_leaf_nodes=3`: 3 leaves (5 nodes); sklearn 1.5.2 `node_count==5`,
5312    /// predict `[1.05]*4 + [5.1,5.1,5.0,5.0]`.
5313    #[test]
5314    fn test_regressor_max_leaf_nodes_3() {
5315        let (x, y) = reg_alt_fixture();
5316        let fitted = fit_reg_max_leaf(&x, &y, Some(3));
5317        assert_eq!(fitted.nodes().len(), 5, "node_count at k=3 (sklearn: 5)");
5318        assert_eq!(reg_n_leaves(&fitted), 3, "n_leaves at k=3 (sklearn: 3)");
5319        assert_reg_predict(&fitted, &x, &[1.05, 1.05, 1.05, 1.05, 5.1, 5.1, 5.0, 5.0]);
5320    }
5321
5322    /// `max_leaf_nodes=4`: 4 leaves (7 nodes); sklearn 1.5.2 `node_count==7`,
5323    /// predict `[1.05]*4 + [5.0,5.2,5.0,5.0]`.
5324    #[test]
5325    fn test_regressor_max_leaf_nodes_4() {
5326        let (x, y) = reg_alt_fixture();
5327        let fitted = fit_reg_max_leaf(&x, &y, Some(4));
5328        assert_eq!(fitted.nodes().len(), 7, "node_count at k=4 (sklearn: 7)");
5329        assert_eq!(reg_n_leaves(&fitted), 4, "n_leaves at k=4 (sklearn: 4)");
5330        assert_reg_predict(&fitted, &x, &[1.05, 1.05, 1.05, 1.05, 5.0, 5.2, 5.0, 5.0]);
5331    }
5332
5333    /// `max_leaf_nodes=5`: 5 leaves (9 nodes); sklearn 1.5.2 `node_count==9`,
5334    /// predict `[1.05]*4 + [5.0,5.2,4.9,5.1]`.
5335    #[test]
5336    fn test_regressor_max_leaf_nodes_5() {
5337        let (x, y) = reg_alt_fixture();
5338        let fitted = fit_reg_max_leaf(&x, &y, Some(5));
5339        assert_eq!(fitted.nodes().len(), 9, "node_count at k=5 (sklearn: 9)");
5340        assert_eq!(reg_n_leaves(&fitted), 5, "n_leaves at k=5 (sklearn: 5)");
5341        assert_reg_predict(&fitted, &x, &[1.05, 1.05, 1.05, 1.05, 5.0, 5.2, 4.9, 5.1]);
5342    }
5343
5344    /// `max_leaf_nodes=None` keeps the depth-first tree unchanged: sklearn
5345    /// 1.5.2 `node_count==15`, predict == y.
5346    #[test]
5347    fn test_regressor_max_leaf_nodes_none_unchanged() {
5348        let (x, y) = reg_alt_fixture();
5349        let fitted = fit_reg_max_leaf(&x, &y, None);
5350        assert_eq!(
5351            fitted.nodes().len(),
5352            15,
5353            "node_count at k=None (sklearn: 15)"
5354        );
5355        assert_reg_predict(&fitted, &x, &[1.0, 1.2, 0.9, 1.1, 5.0, 5.2, 4.9, 5.1]);
5356    }
5357
5358    // -- class_weight (#665) --
5359    //
5360    // Oracle (live sklearn 1.5.2, R-CHAR-3) on the imbalanced 8×2 set:
5361    //   X=[[1,0],[1.5,0],[2,0],[1.2,0],[2.2,0],[5,0],[6,0],[7,0]]
5362    //   y=[0,0,0,1,1,1,1,1], DecisionTreeClassifier(max_depth=1,
5363    //                                                class_weight=CW,
5364    //                                                random_state=0)
5365    //   None       → root (feat=0, thr≈2.1), predict [0,0,0,0,1,1,1,1],
5366    //                proba[0]=[0.75, 0.25]
5367    //   {0:1, 1:5} → root (feat=0, thr≈1.1), predict [0,1,1,1,1,1,1,1],
5368    //                proba[0]=[1.0, 0.0]
5369    //   'balanced' → root (feat=0, thr≈2.1), predict [0,0,0,0,1,1,1,1],
5370    //                proba[0]=[0.8333…, 0.1667…]  (= 3·1.333/(3·1.333+1·0.8))
5371    // Invocation:
5372    //   python3 -c "import numpy as np; from sklearn.tree import \
5373    //   DecisionTreeClassifier; \
5374    //   X=np.array([[1,0],[1.5,0],[2,0],[1.2,0],[2.2,0],[5,0],[6,0],[7,0]]); \
5375    //   y=np.array([0,0,0,1,1,1,1,1]); \
5376    //   m=DecisionTreeClassifier(max_depth=1,class_weight=CW,random_state=0).fit(X,y); \
5377    //   print(m.tree_.feature[0], m.tree_.threshold[0], m.predict(X).tolist(), \
5378    //   m.predict_proba(X)[0].tolist())"
5379
5380    fn cw_fixture() -> (Array2<f64>, Array1<usize>) {
5381        let x = Array2::from_shape_vec(
5382            (8, 2),
5383            vec![
5384                1.0, 0.0, 1.5, 0.0, 2.0, 0.0, 1.2, 0.0, 2.2, 0.0, 5.0, 0.0, 6.0, 0.0, 7.0, 0.0,
5385            ],
5386        )
5387        .unwrap_or_else(|_| Array2::zeros((8, 2)));
5388        let y = array![0, 0, 0, 1, 1, 1, 1, 1];
5389        (x, y)
5390    }
5391
5392    /// Fit a depth-1 classifier with the given `class_weight`, returning the
5393    /// root split `(feature, threshold)`, predictions, and `predict_proba` row 0.
5394    #[allow(
5395        clippy::type_complexity,
5396        reason = "test helper bundling the oracle-compared quantities"
5397    )]
5398    fn cw_fit(cw: ClassWeight<f64>) -> Result<((usize, f64), Vec<usize>, Vec<f64>), FerroError> {
5399        let (x, y) = cw_fixture();
5400        let model = DecisionTreeClassifier::<f64>::new()
5401            .with_max_depth(Some(1))
5402            .with_class_weight(cw);
5403        let fitted = model.fit(&x, &y)?;
5404        let root = match fitted.nodes().first() {
5405            Some(Node::Split {
5406                feature, threshold, ..
5407            }) => (*feature, *threshold),
5408            _ => (usize::MAX, f64::NAN),
5409        };
5410        let preds = fitted.predict(&x)?.to_vec();
5411        let proba0 = fitted.predict_proba(&x)?.row(0).to_vec();
5412        Ok((root, preds, proba0))
5413    }
5414
5415    #[test]
5416    fn test_classifier_class_weight_none_oracle() {
5417        let res = cw_fit(ClassWeight::None);
5418        assert!(res.is_ok(), "fit with class_weight=None must succeed");
5419        let (root, preds, proba0) = res.unwrap_or_else(|_| ((0, 0.0), vec![], vec![]));
5420        assert_eq!(root.0, 0, "None root feature (sklearn: 0)");
5421        assert_relative_eq!(root.1, 2.1, max_relative = 1e-2);
5422        assert_eq!(preds, vec![0, 0, 0, 0, 1, 1, 1, 1], "None predict");
5423        assert_relative_eq!(proba0[0], 0.75, max_relative = 1e-2);
5424        assert_relative_eq!(proba0[1], 0.25, max_relative = 1e-2);
5425    }
5426
5427    #[test]
5428    fn test_classifier_class_weight_explicit_oracle() {
5429        // {0:1, 1:5}: class 1 is up-weighted 5× ⇒ the root threshold shifts to
5430        // ≈1.1, sending only sample 0 (y=0) left.
5431        let res = cw_fit(ClassWeight::Explicit(vec![(0, 1.0), (1, 5.0)]));
5432        assert!(res.is_ok(), "fit with explicit class_weight must succeed");
5433        let (root, preds, proba0) = res.unwrap_or_else(|_| ((0, 0.0), vec![], vec![]));
5434        assert_eq!(root.0, 0, "explicit root feature (sklearn: 0)");
5435        assert_relative_eq!(root.1, 1.1, max_relative = 1e-2);
5436        assert_eq!(preds, vec![0, 1, 1, 1, 1, 1, 1, 1], "explicit predict");
5437        assert_relative_eq!(proba0[0], 1.0, max_relative = 1e-2);
5438        assert!(proba0[1].abs() < 1e-2, "explicit proba[0][1] ≈ 0");
5439    }
5440
5441    #[test]
5442    fn test_classifier_class_weight_balanced_oracle() {
5443        // 'balanced': w0 = 8/(2·3) = 1.333…, w1 = 8/(2·5) = 0.8. The root stays
5444        // (0, ≈2.1); proba[0] = 3·1.333/(3·1.333+1·0.8) ≈ 0.8333.
5445        let res = cw_fit(ClassWeight::Balanced);
5446        assert!(res.is_ok(), "fit with class_weight=balanced must succeed");
5447        let (root, preds, proba0) = res.unwrap_or_else(|_| ((0, 0.0), vec![], vec![]));
5448        assert_eq!(root.0, 0, "balanced root feature (sklearn: 0)");
5449        assert_relative_eq!(root.1, 2.1, max_relative = 1e-2);
5450        assert_eq!(preds, vec![0, 0, 0, 0, 1, 1, 1, 1], "balanced predict");
5451        assert_relative_eq!(proba0[0], 0.833_333_333_333, max_relative = 1e-2);
5452        assert_relative_eq!(proba0[1], 0.166_666_666_666, max_relative = 1e-2);
5453    }
5454
5455    /// `compute_class_weight` matches `sklearn.utils.compute_class_weight`
5456    /// (`class_weight.py:72` balanced `n_samples/(n_classes·count_c)`).
5457    #[test]
5458    fn test_compute_class_weight_balanced() {
5459        // y=[0,0,0,1,1,1,1,1]: count_0=3, count_1=5, n=8, n_classes=2.
5460        let classes = vec![0usize, 1];
5461        let y = vec![0usize, 0, 0, 1, 1, 1, 1, 1];
5462        let bal = compute_class_weight::<f64>(&ClassWeight::Balanced, &classes, &y);
5463        assert_relative_eq!(bal[0], 8.0 / (2.0 * 3.0), max_relative = 1e-12);
5464        assert_relative_eq!(bal[1], 8.0 / (2.0 * 5.0), max_relative = 1e-12);
5465        let none = compute_class_weight::<f64>(&ClassWeight::None, &classes, &y);
5466        assert_relative_eq!(none[0], 1.0, max_relative = 1e-12);
5467        assert_relative_eq!(none[1], 1.0, max_relative = 1e-12);
5468        let exp = compute_class_weight::<f64>(&ClassWeight::Explicit(vec![(1, 5.0)]), &classes, &y);
5469        assert_relative_eq!(exp[0], 1.0, max_relative = 1e-12);
5470        assert_relative_eq!(exp[1], 5.0, max_relative = 1e-12);
5471    }
5472}