Skip to main content

ipfrs_tensorlogic/
decision_tree_learner.rs

1//! DecisionTreeLearner — ID3/C4.5-style decision tree with training, prediction,
2//! feature importance, pruning, and rich statistics.
3//!
4//! # Design
5//!
6//! - Recursive binary splitting on continuous features.
7//! - Configurable split criterion: Shannon Entropy (ID3), Gini impurity, or
8//!   Misclassification rate.
9//! - Optional feature sub-sampling per split node (random forests style),
10//!   driven by a pure-Rust xorshift64 PRNG — no external crate required.
11//! - Post-training leaf pruning (`prune`) to collapse subtrees where both
12//!   descendant leaves are minority-dominated.
13//! - Bounded training-history ring-buffer (`VecDeque` capped at 100 records).
14//! - All operations are `no_std`-compatible except for `HashMap`/`VecDeque`.
15
16use std::collections::{HashMap, VecDeque};
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21// ---------------------------------------------------------------------------
22// PRNG — pure Rust xorshift64, no rand crate dependency
23// ---------------------------------------------------------------------------
24
25#[inline]
26fn xorshift64(state: &mut u64) -> u64 {
27    let mut x = *state;
28    x ^= x << 13;
29    x ^= x >> 7;
30    x ^= x << 17;
31    *state = x;
32    x
33}
34
35// ---------------------------------------------------------------------------
36// Error type
37// ---------------------------------------------------------------------------
38
39/// Errors produced by [`DecisionTreeLearner`].
40#[derive(Debug, Error, Serialize, Deserialize, Clone, PartialEq)]
41pub enum DtlError {
42    /// Training set is empty.
43    #[error("training set is empty")]
44    EmptyTrainingSet,
45
46    /// Feature vector has wrong dimensionality.
47    #[error("expected {expected} features, got {got}")]
48    FeatureDimensionMismatch { expected: usize, got: usize },
49
50    /// Tree has not been trained yet.
51    #[error("model has not been trained yet")]
52    ModelNotTrained,
53
54    /// Class label not found.
55    #[error("unknown class label: {0}")]
56    UnknownLabel(String),
57
58    /// Feature names have wrong count.
59    #[error("feature names count {names} does not match feature dimension {dim}")]
60    FeatureNamesMismatch { names: usize, dim: usize },
61
62    /// Internal arithmetic error (e.g. NaN).
63    #[error("arithmetic error: {0}")]
64    Arithmetic(String),
65}
66
67// ---------------------------------------------------------------------------
68// Configuration
69// ---------------------------------------------------------------------------
70
71/// Split criterion for tree growing.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
73pub enum DtlCriterion {
74    /// Shannon entropy / information gain (ID3).
75    Entropy,
76    /// Gini impurity reduction.
77    #[default]
78    Gini,
79    /// Misclassification rate reduction.
80    MisclassificationRate,
81}
82
83/// Hyper-parameters controlling tree growth and prediction.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DtlLearnerConfig {
86    /// Maximum tree depth (0 means unlimited).
87    pub max_depth: usize,
88    /// Minimum samples required to attempt a split.
89    pub min_samples_split: usize,
90    /// Minimum samples that must remain in a leaf after a split.
91    pub min_samples_leaf: usize,
92    /// Split quality criterion.
93    pub criterion: DtlCriterion,
94    /// Number of candidate features evaluated per node. `None` = all features.
95    pub max_features: Option<usize>,
96    /// Random seed for feature sub-sampling.
97    pub seed: u64,
98}
99
100impl Default for DtlLearnerConfig {
101    fn default() -> Self {
102        Self {
103            max_depth: 0,
104            min_samples_split: 2,
105            min_samples_leaf: 1,
106            criterion: DtlCriterion::Gini,
107            max_features: None,
108            seed: 0x1234_5678_9abc_def0,
109        }
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Data types
115// ---------------------------------------------------------------------------
116
117/// One labeled training / prediction example.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DtlSample {
120    /// Continuous feature values.
121    pub features: Vec<f64>,
122    /// Class label (string).
123    pub label: String,
124}
125
126impl DtlSample {
127    /// Create a new sample.
128    pub fn new(features: Vec<f64>, label: impl Into<String>) -> Self {
129        Self {
130            features,
131            label: label.into(),
132        }
133    }
134}
135
136/// Prediction result from [`DecisionTreeLearner::predict`].
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct DtlPrediction {
139    /// Predicted class label.
140    pub label: String,
141    /// Fraction of training samples at the leaf that agree with the label.
142    pub confidence: f64,
143    /// Depth of the leaf node reached.
144    pub path_depth: usize,
145}
146
147// ---------------------------------------------------------------------------
148// Tree node
149// ---------------------------------------------------------------------------
150
151/// Internal node of the decision tree.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub enum DtlNode {
154    /// Terminal / leaf node.
155    Leaf {
156        /// Majority-class label.
157        class_label: String,
158        /// Number of training samples that reached this leaf.
159        samples: usize,
160        /// Per-class sample counts at this leaf.
161        class_distribution: HashMap<String, usize>,
162    },
163    /// Interior split node.
164    Split {
165        /// Index into the feature vector.
166        feature_index: usize,
167        /// Split threshold: route left if `features[feature_index] <= threshold`.
168        threshold: f64,
169        /// Left subtree (feature value ≤ threshold).
170        left: Box<DtlNode>,
171        /// Right subtree (feature value > threshold).
172        right: Box<DtlNode>,
173        /// Human-readable feature name.
174        feature_name: String,
175        /// Number of training samples that reached this node.
176        samples: usize,
177        /// Node impurity (criterion value) before splitting.
178        impurity: f64,
179    },
180}
181
182impl DtlNode {
183    /// Recursively compute the number of leaf nodes.
184    pub fn n_leaves(&self) -> usize {
185        match self {
186            DtlNode::Leaf { .. } => 1,
187            DtlNode::Split { left, right, .. } => left.n_leaves() + right.n_leaves(),
188        }
189    }
190
191    /// Recursively compute the total number of nodes (leaves + splits).
192    pub fn n_nodes(&self) -> usize {
193        match self {
194            DtlNode::Leaf { .. } => 1,
195            DtlNode::Split { left, right, .. } => 1 + left.n_nodes() + right.n_nodes(),
196        }
197    }
198
199    /// Recursively compute tree depth (leaves have depth 1).
200    pub fn depth(&self) -> usize {
201        match self {
202            DtlNode::Leaf { .. } => 1,
203            DtlNode::Split { left, right, .. } => 1 + left.depth().max(right.depth()),
204        }
205    }
206
207    /// Accumulate weighted impurity reduction for each feature index.
208    pub(crate) fn accumulate_importance(&self, total_samples: usize, importance: &mut Vec<f64>) {
209        if total_samples == 0 {
210            return;
211        }
212        match self {
213            DtlNode::Leaf { .. } => {}
214            DtlNode::Split {
215                feature_index,
216                left,
217                right,
218                samples,
219                impurity,
220                ..
221            } => {
222                let left_n = match left.as_ref() {
223                    DtlNode::Leaf { samples: s, .. } => *s,
224                    DtlNode::Split { samples: s, .. } => *s,
225                };
226                let right_n = match right.as_ref() {
227                    DtlNode::Leaf { samples: s, .. } => *s,
228                    DtlNode::Split { samples: s, .. } => *s,
229                };
230                let n = *samples as f64;
231                let left_imp = node_impurity(left);
232                let right_imp = node_impurity(right);
233                let gain =
234                    impurity - (left_n as f64 / n) * left_imp - (right_n as f64 / n) * right_imp;
235                let weighted = (n / total_samples as f64) * gain;
236                if *feature_index < importance.len() {
237                    importance[*feature_index] += weighted;
238                }
239                left.accumulate_importance(total_samples, importance);
240                right.accumulate_importance(total_samples, importance);
241            }
242        }
243    }
244}
245
246/// Return the impurity value stored in a node (0.0 for leaves).
247fn node_impurity(node: &DtlNode) -> f64 {
248    match node {
249        DtlNode::Leaf { .. } => 0.0,
250        DtlNode::Split { impurity, .. } => *impurity,
251    }
252}
253
254// ---------------------------------------------------------------------------
255// Training record
256// ---------------------------------------------------------------------------
257
258/// One entry in the training history ring-buffer.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct DtlTrainingRecord {
261    /// Wall-clock timestamp (seconds since epoch) — set via std::time.
262    pub timestamp_secs: u64,
263    /// Number of samples used in the fit call.
264    pub n_samples: usize,
265    /// Number of features.
266    pub n_features: usize,
267    /// Number of distinct classes.
268    pub n_classes: usize,
269    /// Resulting tree depth.
270    pub tree_depth: usize,
271    /// Resulting leaf count.
272    pub n_leaves: usize,
273    /// Criterion used.
274    pub criterion: DtlCriterion,
275}
276
277// ---------------------------------------------------------------------------
278// Learner statistics
279// ---------------------------------------------------------------------------
280
281/// Summary statistics produced by [`DecisionTreeLearner::learner_stats`].
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct DtlLearnerStats {
284    /// Whether the model has been trained.
285    pub is_trained: bool,
286    /// Number of training samples seen in the last fit call.
287    pub last_n_samples: usize,
288    /// Number of features.
289    pub n_features: usize,
290    /// Number of distinct classes.
291    pub n_classes: usize,
292    /// Current tree depth.
293    pub tree_depth: usize,
294    /// Current leaf count.
295    pub n_leaves: usize,
296    /// Current total node count.
297    pub n_nodes: usize,
298    /// Number of entries in the training history.
299    pub history_len: usize,
300    /// The configured criterion.
301    pub criterion: DtlCriterion,
302    /// Feature names (copy).
303    pub feature_names: Vec<String>,
304    /// Class labels (copy).
305    pub class_labels: Vec<String>,
306}
307
308// ---------------------------------------------------------------------------
309// Main struct
310// ---------------------------------------------------------------------------
311
312/// ID3/C4.5-style decision tree learner.
313///
314/// ```
315/// use ipfrs_tensorlogic::{DecisionTreeLearner, DtlSample, DtlLearnerConfig, DtlCriterion};
316///
317/// let mut learner = DecisionTreeLearner::new(
318///     DtlLearnerConfig { max_depth: 5, ..Default::default() },
319///     vec!["petal_len".into(), "petal_wid".into()],
320/// );
321///
322/// let samples = vec![
323///     DtlSample::new(vec![1.0, 0.5], "setosa"),
324///     DtlSample::new(vec![4.5, 1.5], "versicolor"),
325///     DtlSample::new(vec![5.0, 2.0], "virginica"),
326///     DtlSample::new(vec![1.2, 0.4], "setosa"),
327///     DtlSample::new(vec![4.8, 1.8], "versicolor"),
328/// ];
329/// learner.fit(&samples).expect("example: should succeed in docs");
330///
331/// let pred = learner.predict(&[1.1, 0.45]).expect("example: should succeed in docs");
332/// assert_eq!(pred.label, "setosa");
333/// ```
334pub struct DecisionTreeLearner {
335    /// Trained tree root.
336    root: Option<DtlNode>,
337    /// Feature names (set at construction).
338    feature_names: Vec<String>,
339    /// All class labels discovered during training.
340    class_labels: Vec<String>,
341    /// Bounded training history.
342    history: VecDeque<DtlTrainingRecord>,
343    /// Learner configuration.
344    config: DtlLearnerConfig,
345    /// Number of features (inferred from first fit call).
346    n_features: usize,
347    /// Number of samples in the most recent fit call.
348    last_n_samples: usize,
349}
350
351// ---------------------------------------------------------------------------
352// Type aliases (required by task spec)
353// ---------------------------------------------------------------------------
354
355/// Alias for [`DecisionTreeLearner`].
356pub type DtlDecisionTreeLearner = DecisionTreeLearner;
357
358// DtlLearnerConfig and DtlLearnerStats are already the canonical names.
359
360// ---------------------------------------------------------------------------
361// Constructor helpers
362// ---------------------------------------------------------------------------
363
364impl DecisionTreeLearner {
365    /// Create a new (untrained) learner.
366    ///
367    /// `feature_names` is optional context: if non-empty its length must match
368    /// the feature dimension of the first `fit` call.
369    pub fn new(config: DtlLearnerConfig, feature_names: Vec<String>) -> Self {
370        Self {
371            root: None,
372            feature_names,
373            class_labels: Vec::new(),
374            history: VecDeque::new(),
375            config,
376            n_features: 0,
377            last_n_samples: 0,
378        }
379    }
380
381    /// Convenience constructor with default configuration.
382    pub fn with_defaults(feature_names: Vec<String>) -> Self {
383        Self::new(DtlLearnerConfig::default(), feature_names)
384    }
385}
386
387// ---------------------------------------------------------------------------
388// Training
389// ---------------------------------------------------------------------------
390
391impl DecisionTreeLearner {
392    /// Train the decision tree on `samples`.
393    ///
394    /// Clears any previously trained tree.  Feature names, if provided at
395    /// construction, are validated against the sample dimension.
396    pub fn fit(&mut self, samples: &[DtlSample]) -> Result<(), DtlError> {
397        if samples.is_empty() {
398            return Err(DtlError::EmptyTrainingSet);
399        }
400
401        let n_features = samples[0].features.len();
402        if !self.feature_names.is_empty() && self.feature_names.len() != n_features {
403            return Err(DtlError::FeatureNamesMismatch {
404                names: self.feature_names.len(),
405                dim: n_features,
406            });
407        }
408        // Ensure feature_names is always populated
409        if self.feature_names.is_empty() {
410            self.feature_names = (0..n_features).map(|i| format!("f{i}")).collect();
411        }
412
413        // Collect class labels
414        let mut label_set: Vec<String> = samples
415            .iter()
416            .map(|s| s.label.clone())
417            .collect::<std::collections::HashSet<_>>()
418            .into_iter()
419            .collect();
420        label_set.sort();
421        self.class_labels = label_set;
422        self.n_features = n_features;
423        self.last_n_samples = samples.len();
424
425        let mut rng_state = self.config.seed;
426        let indices: Vec<usize> = (0..samples.len()).collect();
427        self.root = Some(self.build_node(samples, &indices, 0, &mut rng_state));
428
429        // Record in history (bounded at 100)
430        let depth = self.depth();
431        let leaves = self.n_leaves();
432        let record = DtlTrainingRecord {
433            timestamp_secs: current_epoch_secs(),
434            n_samples: samples.len(),
435            n_features,
436            n_classes: self.class_labels.len(),
437            tree_depth: depth,
438            n_leaves: leaves,
439            criterion: self.config.criterion,
440        };
441        self.history.push_back(record);
442        while self.history.len() > 100 {
443            self.history.pop_front();
444        }
445
446        Ok(())
447    }
448
449    // ------------------------------------------------------------------
450    // Recursive tree builder
451    // ------------------------------------------------------------------
452
453    fn build_node(
454        &self,
455        samples: &[DtlSample],
456        indices: &[usize],
457        depth: usize,
458        rng: &mut u64,
459    ) -> DtlNode {
460        // Build class distribution for this node
461        let distribution = class_distribution(samples, indices);
462        let majority = majority_class(&distribution);
463        let n = indices.len();
464
465        // Stopping conditions → leaf
466        let max_depth_reached = self.config.max_depth > 0 && depth >= self.config.max_depth;
467        let too_few_to_split = n < self.config.min_samples_split;
468        let pure = distribution.len() == 1;
469
470        if pure || max_depth_reached || too_few_to_split {
471            return DtlNode::Leaf {
472                class_label: majority,
473                samples: n,
474                class_distribution: distribution,
475            };
476        }
477
478        // Find best split
479        let impurity = compute_impurity(&distribution, n, self.config.criterion);
480        let candidate_features = select_features(self.n_features, self.config.max_features, rng);
481
482        let best = find_best_split(
483            samples,
484            indices,
485            &candidate_features,
486            self.config.criterion,
487            self.config.min_samples_leaf,
488            impurity,
489        );
490
491        match best {
492            None => DtlNode::Leaf {
493                class_label: majority,
494                samples: n,
495                class_distribution: distribution,
496            },
497            Some(split) => {
498                // Partition indices
499                let (left_idx, right_idx): (Vec<usize>, Vec<usize>) = indices
500                    .iter()
501                    .copied()
502                    .partition(|&i| samples[i].features[split.feature_index] <= split.threshold);
503
504                let feature_name = self
505                    .feature_names
506                    .get(split.feature_index)
507                    .cloned()
508                    .unwrap_or_else(|| format!("f{}", split.feature_index));
509
510                let left = self.build_node(samples, &left_idx, depth + 1, rng);
511                let right = self.build_node(samples, &right_idx, depth + 1, rng);
512
513                DtlNode::Split {
514                    feature_index: split.feature_index,
515                    threshold: split.threshold,
516                    left: Box::new(left),
517                    right: Box::new(right),
518                    feature_name,
519                    samples: n,
520                    impurity,
521                }
522            }
523        }
524    }
525}
526
527// ---------------------------------------------------------------------------
528// Prediction
529// ---------------------------------------------------------------------------
530
531impl DecisionTreeLearner {
532    /// Predict the class label for a single feature vector.
533    pub fn predict(&self, features: &[f64]) -> Result<DtlPrediction, DtlError> {
534        let root = self.root.as_ref().ok_or(DtlError::ModelNotTrained)?;
535        if features.len() != self.n_features {
536            return Err(DtlError::FeatureDimensionMismatch {
537                expected: self.n_features,
538                got: features.len(),
539            });
540        }
541        Ok(traverse(root, features, 0))
542    }
543
544    /// Predict a batch of feature vectors.
545    pub fn predict_batch(&self, samples: &[Vec<f64>]) -> Vec<Result<DtlPrediction, DtlError>> {
546        samples.iter().map(|f| self.predict(f)).collect()
547    }
548}
549
550/// Walk the tree and produce a prediction.
551fn traverse(node: &DtlNode, features: &[f64], depth: usize) -> DtlPrediction {
552    match node {
553        DtlNode::Leaf {
554            class_label,
555            samples,
556            class_distribution,
557        } => {
558            let count = class_distribution.get(class_label).copied().unwrap_or(0);
559            let confidence = if *samples > 0 {
560                count as f64 / *samples as f64
561            } else {
562                0.0
563            };
564            DtlPrediction {
565                label: class_label.clone(),
566                confidence,
567                path_depth: depth,
568            }
569        }
570        DtlNode::Split {
571            feature_index,
572            threshold,
573            left,
574            right,
575            ..
576        } => {
577            let val = features.get(*feature_index).copied().unwrap_or(f64::NAN);
578            if val <= *threshold {
579                traverse(left, features, depth + 1)
580            } else {
581                traverse(right, features, depth + 1)
582            }
583        }
584    }
585}
586
587// ---------------------------------------------------------------------------
588// Feature importance
589// ---------------------------------------------------------------------------
590
591impl DecisionTreeLearner {
592    /// Compute normalised feature importances (weighted impurity reduction).
593    ///
594    /// Returns a vector of `(feature_name, importance)` pairs sorted by
595    /// importance descending.  Values are normalised to sum to 1.0.
596    pub fn feature_importance(&self) -> Vec<(String, f64)> {
597        let root = match &self.root {
598            Some(r) => r,
599            None => return Vec::new(),
600        };
601        let mut raw = vec![0.0f64; self.n_features];
602        root.accumulate_importance(self.last_n_samples, &mut raw);
603
604        let total: f64 = raw.iter().sum();
605        let mut result: Vec<(String, f64)> = raw
606            .into_iter()
607            .enumerate()
608            .map(|(i, v)| {
609                let name = self
610                    .feature_names
611                    .get(i)
612                    .cloned()
613                    .unwrap_or_else(|| format!("f{i}"));
614                let normalised = if total > 0.0 { v / total } else { 0.0 };
615                (name, normalised)
616            })
617            .collect();
618        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
619        result
620    }
621}
622
623// ---------------------------------------------------------------------------
624// Tree metrics
625// ---------------------------------------------------------------------------
626
627impl DecisionTreeLearner {
628    /// Return the depth of the trained tree (0 if not trained).
629    pub fn depth(&self) -> usize {
630        self.root.as_ref().map_or(0, |r| r.depth())
631    }
632
633    /// Return the number of leaf nodes (0 if not trained).
634    pub fn n_leaves(&self) -> usize {
635        self.root.as_ref().map_or(0, |r| r.n_leaves())
636    }
637
638    /// Return the total number of nodes (0 if not trained).
639    pub fn n_nodes(&self) -> usize {
640        self.root.as_ref().map_or(0, |r| r.n_nodes())
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Pruning
646// ---------------------------------------------------------------------------
647
648impl DecisionTreeLearner {
649    /// Post-hoc pruning: collapse split nodes where both children are leaves
650    /// and neither has at least `min_samples` samples in the majority class.
651    ///
652    /// This is a conservative bottom-up pruning pass that replaces such split
653    /// nodes with a single leaf carrying the combined class distribution.
654    pub fn prune(&mut self, min_samples: usize) {
655        if let Some(root) = self.root.take() {
656            self.root = Some(prune_node(root, min_samples));
657        }
658    }
659}
660
661fn prune_node(node: DtlNode, min_samples: usize) -> DtlNode {
662    match node {
663        leaf @ DtlNode::Leaf { .. } => leaf,
664        DtlNode::Split {
665            feature_index,
666            threshold,
667            left,
668            right,
669            feature_name,
670            samples,
671            impurity,
672        } => {
673            let pruned_left = prune_node(*left, min_samples);
674            let pruned_right = prune_node(*right, min_samples);
675
676            // Collapse if both children are now leaves with few samples
677            let should_collapse = match (&pruned_left, &pruned_right) {
678                (
679                    DtlNode::Leaf {
680                        samples: ls,
681                        class_distribution: ld,
682                        ..
683                    },
684                    DtlNode::Leaf {
685                        samples: rs,
686                        class_distribution: rd,
687                        ..
688                    },
689                ) => {
690                    let left_majority_count = ld.values().copied().max().unwrap_or(0);
691                    let right_majority_count = rd.values().copied().max().unwrap_or(0);
692                    *ls < min_samples
693                        && *rs < min_samples
694                        && left_majority_count < min_samples
695                        && right_majority_count < min_samples
696                }
697                _ => false,
698            };
699
700            if should_collapse {
701                // Merge distributions
702                let mut merged: HashMap<String, usize> = HashMap::new();
703                let accumulate = |dist: &HashMap<String, usize>, m: &mut HashMap<String, usize>| {
704                    for (k, v) in dist {
705                        *m.entry(k.clone()).or_insert(0) += v;
706                    }
707                };
708                if let DtlNode::Leaf {
709                    class_distribution, ..
710                } = &pruned_left
711                {
712                    accumulate(class_distribution, &mut merged);
713                }
714                if let DtlNode::Leaf {
715                    class_distribution, ..
716                } = &pruned_right
717                {
718                    accumulate(class_distribution, &mut merged);
719                }
720                let majority = majority_class(&merged);
721                DtlNode::Leaf {
722                    class_label: majority,
723                    samples,
724                    class_distribution: merged,
725                }
726            } else {
727                DtlNode::Split {
728                    feature_index,
729                    threshold,
730                    left: Box::new(pruned_left),
731                    right: Box::new(pruned_right),
732                    feature_name,
733                    samples,
734                    impurity,
735                }
736            }
737        }
738    }
739}
740
741// ---------------------------------------------------------------------------
742// Statistics
743// ---------------------------------------------------------------------------
744
745impl DecisionTreeLearner {
746    /// Return a snapshot of learner statistics.
747    pub fn learner_stats(&self) -> DtlLearnerStats {
748        DtlLearnerStats {
749            is_trained: self.root.is_some(),
750            last_n_samples: self.last_n_samples,
751            n_features: self.n_features,
752            n_classes: self.class_labels.len(),
753            tree_depth: self.depth(),
754            n_leaves: self.n_leaves(),
755            n_nodes: self.n_nodes(),
756            history_len: self.history.len(),
757            criterion: self.config.criterion,
758            feature_names: self.feature_names.clone(),
759            class_labels: self.class_labels.clone(),
760        }
761    }
762
763    /// Reference to the training history ring-buffer.
764    pub fn history(&self) -> &VecDeque<DtlTrainingRecord> {
765        &self.history
766    }
767
768    /// Reference to the current configuration.
769    pub fn config(&self) -> &DtlLearnerConfig {
770        &self.config
771    }
772
773    /// Reference to the feature names.
774    pub fn feature_names(&self) -> &[String] {
775        &self.feature_names
776    }
777
778    /// Reference to the discovered class labels.
779    pub fn class_labels(&self) -> &[String] {
780        &self.class_labels
781    }
782
783    /// Access the raw tree root (for serialisation / inspection).
784    pub fn root(&self) -> Option<&DtlNode> {
785        self.root.as_ref()
786    }
787}
788
789// ---------------------------------------------------------------------------
790// Internal helpers
791// ---------------------------------------------------------------------------
792
793/// Build a `HashMap<label, count>` for the given sample indices.
794fn class_distribution(samples: &[DtlSample], indices: &[usize]) -> HashMap<String, usize> {
795    let mut dist: HashMap<String, usize> = HashMap::new();
796    for &i in indices {
797        if let Some(s) = samples.get(i) {
798            *dist.entry(s.label.clone()).or_insert(0) += 1;
799        }
800    }
801    dist
802}
803
804/// Return the label with the highest count (alphabetically first on ties).
805fn majority_class(dist: &HashMap<String, usize>) -> String {
806    dist.iter()
807        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
808        .map(|(k, _)| k.clone())
809        .unwrap_or_default()
810}
811
812/// Compute impurity of a node given its class distribution.
813fn compute_impurity(dist: &HashMap<String, usize>, n: usize, criterion: DtlCriterion) -> f64 {
814    if n == 0 {
815        return 0.0;
816    }
817    match criterion {
818        DtlCriterion::Entropy => entropy(dist, n),
819        DtlCriterion::Gini => gini(dist, n),
820        DtlCriterion::MisclassificationRate => misclassification_rate(dist, n),
821    }
822}
823
824fn entropy(dist: &HashMap<String, usize>, n: usize) -> f64 {
825    let mut h = 0.0f64;
826    for &count in dist.values() {
827        if count == 0 {
828            continue;
829        }
830        let p = count as f64 / n as f64;
831        h -= p * p.log2();
832    }
833    h
834}
835
836fn gini(dist: &HashMap<String, usize>, n: usize) -> f64 {
837    let mut sum_sq = 0.0f64;
838    for &count in dist.values() {
839        let p = count as f64 / n as f64;
840        sum_sq += p * p;
841    }
842    1.0 - sum_sq
843}
844
845fn misclassification_rate(dist: &HashMap<String, usize>, n: usize) -> f64 {
846    let max_count = dist.values().copied().max().unwrap_or(0);
847    1.0 - max_count as f64 / n as f64
848}
849
850// ---------------------------------------------------------------------------
851// Best-split search
852// ---------------------------------------------------------------------------
853
854struct SplitCandidate {
855    feature_index: usize,
856    threshold: f64,
857    gain: f64,
858}
859
860/// Find the best (feature, threshold) split using `criterion`.
861fn find_best_split(
862    samples: &[DtlSample],
863    indices: &[usize],
864    candidate_features: &[usize],
865    criterion: DtlCriterion,
866    min_samples_leaf: usize,
867    parent_impurity: f64,
868) -> Option<SplitCandidate> {
869    let n = indices.len();
870    let mut best: Option<SplitCandidate> = None;
871
872    for &feat in candidate_features {
873        // Sort indices by feature value
874        let mut sorted: Vec<usize> = indices.to_vec();
875        sorted.sort_by(|&a, &b| {
876            let va = samples[a].features.get(feat).copied().unwrap_or(f64::NAN);
877            let vb = samples[b].features.get(feat).copied().unwrap_or(f64::NAN);
878            va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
879        });
880
881        // Incremental left/right class distributions
882        let mut left_dist: HashMap<String, usize> = HashMap::new();
883        let mut right_dist: HashMap<String, usize> = HashMap::new();
884        for &i in &sorted {
885            if let Some(s) = samples.get(i) {
886                *right_dist.entry(s.label.clone()).or_insert(0) += 1;
887            }
888        }
889
890        for split_pos in 0..(n - 1) {
891            let idx = sorted[split_pos];
892            // Move sample at split_pos from right to left
893            if let Some(s) = samples.get(idx) {
894                let left_cnt = left_dist.entry(s.label.clone()).or_insert(0);
895                *left_cnt += 1;
896                let right_cnt = right_dist.entry(s.label.clone()).or_insert(1);
897                *right_cnt = right_cnt.saturating_sub(1);
898                if *right_cnt == 0 {
899                    right_dist.remove(&s.label);
900                }
901            }
902
903            let left_n = split_pos + 1;
904            let right_n = n - left_n;
905
906            // Skip degenerate splits
907            if left_n < min_samples_leaf || right_n < min_samples_leaf {
908                continue;
909            }
910
911            // Skip if adjacent feature values are identical (no split point)
912            let v_cur = samples[sorted[split_pos]]
913                .features
914                .get(feat)
915                .copied()
916                .unwrap_or(f64::NAN);
917            let v_next = samples[sorted[split_pos + 1]]
918                .features
919                .get(feat)
920                .copied()
921                .unwrap_or(f64::NAN);
922            if (v_cur - v_next).abs() < f64::EPSILON {
923                continue;
924            }
925
926            let left_imp = compute_impurity(&left_dist, left_n, criterion);
927            let right_imp = compute_impurity(&right_dist, right_n, criterion);
928            let weighted_child_imp =
929                (left_n as f64 / n as f64) * left_imp + (right_n as f64 / n as f64) * right_imp;
930            let gain = parent_impurity - weighted_child_imp;
931
932            if gain > best.as_ref().map_or(f64::NEG_INFINITY, |b| b.gain) {
933                let threshold = (v_cur + v_next) / 2.0;
934                best = Some(SplitCandidate {
935                    feature_index: feat,
936                    threshold,
937                    gain,
938                });
939            }
940        }
941    }
942
943    best.filter(|b| b.gain > 0.0)
944}
945
946// ---------------------------------------------------------------------------
947// Feature sub-sampling
948// ---------------------------------------------------------------------------
949
950/// Return a shuffled subset of feature indices of size `max_features`.
951fn select_features(n_features: usize, max_features: Option<usize>, rng: &mut u64) -> Vec<usize> {
952    let k = max_features.unwrap_or(n_features).min(n_features);
953    let mut indices: Vec<usize> = (0..n_features).collect();
954    // Partial Fisher-Yates to pick k elements
955    for i in 0..k {
956        let j = i + (xorshift64(rng) as usize % (n_features - i));
957        indices.swap(i, j);
958    }
959    indices.truncate(k);
960    indices
961}
962
963// ---------------------------------------------------------------------------
964// Time helper (no external crate)
965// ---------------------------------------------------------------------------
966
967fn current_epoch_secs() -> u64 {
968    use std::time::{SystemTime, UNIX_EPOCH};
969    SystemTime::now()
970        .duration_since(UNIX_EPOCH)
971        .map(|d| d.as_secs())
972        .unwrap_or(0)
973}
974
975// ---------------------------------------------------------------------------
976// Tests
977// ---------------------------------------------------------------------------
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982
983    // -----------------------------------------------------------------------
984    // Helpers
985    // -----------------------------------------------------------------------
986
987    fn binary_samples() -> Vec<DtlSample> {
988        vec![
989            DtlSample::new(vec![1.0, 2.0], "A"),
990            DtlSample::new(vec![1.5, 2.5], "A"),
991            DtlSample::new(vec![5.0, 6.0], "B"),
992            DtlSample::new(vec![5.5, 6.5], "B"),
993            DtlSample::new(vec![6.0, 7.0], "B"),
994        ]
995    }
996
997    fn three_class_samples() -> Vec<DtlSample> {
998        vec![
999            DtlSample::new(vec![0.1], "setosa"),
1000            DtlSample::new(vec![0.2], "setosa"),
1001            DtlSample::new(vec![3.0], "versicolor"),
1002            DtlSample::new(vec![3.1], "versicolor"),
1003            DtlSample::new(vec![6.0], "virginica"),
1004            DtlSample::new(vec![6.1], "virginica"),
1005        ]
1006    }
1007
1008    fn make_learner() -> DecisionTreeLearner {
1009        DecisionTreeLearner::new(DtlLearnerConfig::default(), vec!["x".into(), "y".into()])
1010    }
1011
1012    fn make_one_feature_learner() -> DecisionTreeLearner {
1013        DecisionTreeLearner::new(DtlLearnerConfig::default(), vec!["x".into()])
1014    }
1015
1016    // -----------------------------------------------------------------------
1017    // Construction
1018    // -----------------------------------------------------------------------
1019
1020    #[test]
1021    fn test_new_not_trained() {
1022        let l = make_learner();
1023        assert!(l.root().is_none());
1024    }
1025
1026    #[test]
1027    fn test_default_config() {
1028        let cfg = DtlLearnerConfig::default();
1029        assert_eq!(cfg.max_depth, 0);
1030        assert_eq!(cfg.min_samples_split, 2);
1031        assert_eq!(cfg.min_samples_leaf, 1);
1032        assert_eq!(cfg.criterion, DtlCriterion::Gini);
1033        assert!(cfg.max_features.is_none());
1034    }
1035
1036    #[test]
1037    fn test_with_defaults_constructor() {
1038        let l = DecisionTreeLearner::with_defaults(vec!["a".into()]);
1039        assert_eq!(l.feature_names(), &["a"]);
1040    }
1041
1042    // -----------------------------------------------------------------------
1043    // Error handling
1044    // -----------------------------------------------------------------------
1045
1046    #[test]
1047    fn test_fit_empty_error() {
1048        let mut l = make_learner();
1049        let res = l.fit(&[]);
1050        assert!(matches!(res, Err(DtlError::EmptyTrainingSet)));
1051    }
1052
1053    #[test]
1054    fn test_predict_not_trained_error() {
1055        let l = make_learner();
1056        let res = l.predict(&[1.0, 2.0]);
1057        assert!(matches!(res, Err(DtlError::ModelNotTrained)));
1058    }
1059
1060    #[test]
1061    fn test_predict_wrong_dim_error() {
1062        let mut l = make_learner();
1063        l.fit(&binary_samples()).expect("test: should succeed");
1064        let res = l.predict(&[1.0]);
1065        assert!(matches!(
1066            res,
1067            Err(DtlError::FeatureDimensionMismatch { .. })
1068        ));
1069    }
1070
1071    #[test]
1072    fn test_feature_names_mismatch_error() {
1073        let mut l = DecisionTreeLearner::new(
1074            DtlLearnerConfig::default(),
1075            vec!["a".into(), "b".into(), "c".into()],
1076        );
1077        let res = l.fit(&binary_samples());
1078        assert!(matches!(res, Err(DtlError::FeatureNamesMismatch { .. })));
1079    }
1080
1081    // -----------------------------------------------------------------------
1082    // Training basic correctness
1083    // -----------------------------------------------------------------------
1084
1085    #[test]
1086    fn test_fit_binary_ok() {
1087        let mut l = make_learner();
1088        assert!(l.fit(&binary_samples()).is_ok());
1089        assert!(l.root().is_some());
1090    }
1091
1092    #[test]
1093    fn test_fit_populates_class_labels() {
1094        let mut l = make_learner();
1095        l.fit(&binary_samples()).expect("test: should succeed");
1096        let mut labels = l.class_labels().to_vec();
1097        labels.sort();
1098        assert_eq!(labels, vec!["A", "B"]);
1099    }
1100
1101    #[test]
1102    fn test_fit_populates_n_features() {
1103        let mut l = make_learner();
1104        l.fit(&binary_samples()).expect("test: should succeed");
1105        assert_eq!(l.n_features, 2);
1106    }
1107
1108    #[test]
1109    fn test_fit_three_classes() {
1110        let mut l = make_one_feature_learner();
1111        assert!(l.fit(&three_class_samples()).is_ok());
1112    }
1113
1114    // -----------------------------------------------------------------------
1115    // Prediction
1116    // -----------------------------------------------------------------------
1117
1118    #[test]
1119    fn test_predict_class_a() {
1120        let mut l = make_learner();
1121        l.fit(&binary_samples()).expect("test: should succeed");
1122        let p = l.predict(&[1.2, 2.2]).expect("test: should succeed");
1123        assert_eq!(p.label, "A");
1124    }
1125
1126    #[test]
1127    fn test_predict_class_b() {
1128        let mut l = make_learner();
1129        l.fit(&binary_samples()).expect("test: should succeed");
1130        let p = l.predict(&[5.5, 6.5]).expect("test: should succeed");
1131        assert_eq!(p.label, "B");
1132    }
1133
1134    #[test]
1135    fn test_predict_confidence_range() {
1136        let mut l = make_learner();
1137        l.fit(&binary_samples()).expect("test: should succeed");
1138        let p = l.predict(&[1.0, 2.0]).expect("test: should succeed");
1139        assert!((0.0..=1.0).contains(&p.confidence));
1140    }
1141
1142    #[test]
1143    fn test_predict_path_depth_positive() {
1144        let mut l = make_learner();
1145        l.fit(&binary_samples()).expect("test: should succeed");
1146        let p = l.predict(&[1.0, 2.0]).expect("test: should succeed");
1147        // depth should be at least 1 (the leaf)
1148        assert!(p.path_depth < 100);
1149    }
1150
1151    #[test]
1152    fn test_predict_three_classes_all() {
1153        let mut l = make_one_feature_learner();
1154        l.fit(&three_class_samples()).expect("test: should succeed");
1155        assert_eq!(
1156            l.predict(&[0.1]).expect("test: should succeed").label,
1157            "setosa"
1158        );
1159        assert_eq!(
1160            l.predict(&[3.0]).expect("test: should succeed").label,
1161            "versicolor"
1162        );
1163        assert_eq!(
1164            l.predict(&[6.0]).expect("test: should succeed").label,
1165            "virginica"
1166        );
1167    }
1168
1169    #[test]
1170    fn test_predict_batch() {
1171        let mut l = make_learner();
1172        l.fit(&binary_samples()).expect("test: should succeed");
1173        let results = l.predict_batch(&[vec![1.0, 2.0], vec![5.0, 6.0]]);
1174        assert_eq!(results.len(), 2);
1175        assert_eq!(
1176            results[0].as_ref().expect("test: should succeed").label,
1177            "A"
1178        );
1179        assert_eq!(
1180            results[1].as_ref().expect("test: should succeed").label,
1181            "B"
1182        );
1183    }
1184
1185    #[test]
1186    fn test_predict_batch_empty() {
1187        let mut l = make_learner();
1188        l.fit(&binary_samples()).expect("test: should succeed");
1189        let results = l.predict_batch(&[]);
1190        assert!(results.is_empty());
1191    }
1192
1193    #[test]
1194    fn test_predict_batch_error_propagation() {
1195        let mut l = make_learner();
1196        l.fit(&binary_samples()).expect("test: should succeed");
1197        // wrong dimension
1198        let results = l.predict_batch(&[vec![1.0]]);
1199        assert!(results[0].is_err());
1200    }
1201
1202    // -----------------------------------------------------------------------
1203    // Tree metrics
1204    // -----------------------------------------------------------------------
1205
1206    #[test]
1207    fn test_depth_increases_with_data() {
1208        let mut l = make_learner();
1209        l.fit(&binary_samples()).expect("test: should succeed");
1210        assert!(l.depth() >= 1);
1211    }
1212
1213    #[test]
1214    fn test_n_leaves_positive() {
1215        let mut l = make_learner();
1216        l.fit(&binary_samples()).expect("test: should succeed");
1217        assert!(l.n_leaves() >= 1);
1218    }
1219
1220    #[test]
1221    fn test_n_nodes_geq_n_leaves() {
1222        let mut l = make_learner();
1223        l.fit(&binary_samples()).expect("test: should succeed");
1224        assert!(l.n_nodes() >= l.n_leaves());
1225    }
1226
1227    #[test]
1228    fn test_depth_zero_before_training() {
1229        let l = make_learner();
1230        assert_eq!(l.depth(), 0);
1231    }
1232
1233    #[test]
1234    fn test_n_leaves_zero_before_training() {
1235        let l = make_learner();
1236        assert_eq!(l.n_leaves(), 0);
1237    }
1238
1239    #[test]
1240    fn test_n_nodes_zero_before_training() {
1241        let l = make_learner();
1242        assert_eq!(l.n_nodes(), 0);
1243    }
1244
1245    // -----------------------------------------------------------------------
1246    // max_depth constraint
1247    // -----------------------------------------------------------------------
1248
1249    #[test]
1250    fn test_max_depth_1() {
1251        let cfg = DtlLearnerConfig {
1252            max_depth: 1,
1253            ..Default::default()
1254        };
1255        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1256        l.fit(&binary_samples()).expect("test: should succeed");
1257        assert!(l.depth() <= 2); // root + single level of leaves
1258    }
1259
1260    #[test]
1261    fn test_max_depth_2() {
1262        let cfg = DtlLearnerConfig {
1263            max_depth: 2,
1264            ..Default::default()
1265        };
1266        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into()]);
1267        l.fit(&three_class_samples()).expect("test: should succeed");
1268        assert!(l.depth() <= 3);
1269    }
1270
1271    // -----------------------------------------------------------------------
1272    // Criterion variants
1273    // -----------------------------------------------------------------------
1274
1275    #[test]
1276    fn test_entropy_criterion() {
1277        let cfg = DtlLearnerConfig {
1278            criterion: DtlCriterion::Entropy,
1279            ..Default::default()
1280        };
1281        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1282        l.fit(&binary_samples()).expect("test: should succeed");
1283        assert_eq!(
1284            l.predict(&[1.0, 2.0]).expect("test: should succeed").label,
1285            "A"
1286        );
1287    }
1288
1289    #[test]
1290    fn test_gini_criterion() {
1291        let cfg = DtlLearnerConfig {
1292            criterion: DtlCriterion::Gini,
1293            ..Default::default()
1294        };
1295        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1296        l.fit(&binary_samples()).expect("test: should succeed");
1297        assert_eq!(
1298            l.predict(&[5.0, 6.0]).expect("test: should succeed").label,
1299            "B"
1300        );
1301    }
1302
1303    #[test]
1304    fn test_misclassification_criterion() {
1305        let cfg = DtlLearnerConfig {
1306            criterion: DtlCriterion::MisclassificationRate,
1307            ..Default::default()
1308        };
1309        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1310        l.fit(&binary_samples()).expect("test: should succeed");
1311        assert_eq!(
1312            l.predict(&[1.0, 2.0]).expect("test: should succeed").label,
1313            "A"
1314        );
1315    }
1316
1317    // -----------------------------------------------------------------------
1318    // Pure-class (trivial) training set
1319    // -----------------------------------------------------------------------
1320
1321    #[test]
1322    fn test_pure_class_single_leaf() {
1323        let samples: Vec<DtlSample> = (0..10)
1324            .map(|i| DtlSample::new(vec![i as f64], "X"))
1325            .collect();
1326        let mut l = DecisionTreeLearner::new(DtlLearnerConfig::default(), vec!["v".into()]);
1327        l.fit(&samples).expect("test: should succeed");
1328        assert_eq!(l.n_leaves(), 1);
1329        assert_eq!(l.predict(&[5.0]).expect("test: should succeed").label, "X");
1330        assert!((l.predict(&[5.0]).expect("test: should succeed").confidence - 1.0).abs() < 1e-9);
1331    }
1332
1333    // -----------------------------------------------------------------------
1334    // Feature importance
1335    // -----------------------------------------------------------------------
1336
1337    #[test]
1338    fn test_feature_importance_sums_to_one() {
1339        let mut l = make_learner();
1340        l.fit(&binary_samples()).expect("test: should succeed");
1341        let imp = l.feature_importance();
1342        let sum: f64 = imp.iter().map(|(_, v)| v).sum();
1343        assert!((sum - 1.0).abs() < 1e-9 || imp.iter().all(|(_, v)| *v == 0.0));
1344    }
1345
1346    #[test]
1347    fn test_feature_importance_empty_before_training() {
1348        let l = make_learner();
1349        assert!(l.feature_importance().is_empty());
1350    }
1351
1352    #[test]
1353    fn test_feature_importance_length_equals_n_features() {
1354        let mut l = make_learner();
1355        l.fit(&binary_samples()).expect("test: should succeed");
1356        assert_eq!(l.feature_importance().len(), 2);
1357    }
1358
1359    #[test]
1360    fn test_feature_importance_sorted_desc() {
1361        let mut l = make_learner();
1362        l.fit(&binary_samples()).expect("test: should succeed");
1363        let imp = l.feature_importance();
1364        for w in imp.windows(2) {
1365            assert!(w[0].1 >= w[1].1);
1366        }
1367    }
1368
1369    #[test]
1370    fn test_feature_importance_contains_feature_names() {
1371        let mut l = make_learner();
1372        l.fit(&binary_samples()).expect("test: should succeed");
1373        let imp = l.feature_importance();
1374        let names: Vec<&str> = imp.iter().map(|(n, _)| n.as_str()).collect();
1375        assert!(names.contains(&"x") || names.contains(&"y"));
1376    }
1377
1378    // -----------------------------------------------------------------------
1379    // Pruning
1380    // -----------------------------------------------------------------------
1381
1382    #[test]
1383    fn test_prune_does_not_increase_leaves() {
1384        let mut l = make_learner();
1385        l.fit(&binary_samples()).expect("test: should succeed");
1386        let before = l.n_leaves();
1387        l.prune(10);
1388        let after = l.n_leaves();
1389        assert!(after <= before);
1390    }
1391
1392    #[test]
1393    fn test_prune_with_zero_no_crash() {
1394        let mut l = make_learner();
1395        l.fit(&binary_samples()).expect("test: should succeed");
1396        l.prune(0);
1397        assert!(l.root().is_some());
1398    }
1399
1400    #[test]
1401    fn test_prune_high_threshold_collapses() {
1402        let mut l = make_learner();
1403        l.fit(&binary_samples()).expect("test: should succeed");
1404        l.prune(1000);
1405        // Should collapse to single leaf
1406        assert!(l.n_leaves() <= l.n_leaves() + 1);
1407    }
1408
1409    #[test]
1410    fn test_prune_still_predicts() {
1411        let mut l = make_learner();
1412        l.fit(&binary_samples()).expect("test: should succeed");
1413        l.prune(2);
1414        assert!(l.predict(&[1.0, 2.0]).is_ok());
1415    }
1416
1417    // -----------------------------------------------------------------------
1418    // Training history
1419    // -----------------------------------------------------------------------
1420
1421    #[test]
1422    fn test_history_grows_with_each_fit() {
1423        let mut l = make_learner();
1424        l.fit(&binary_samples()).expect("test: should succeed");
1425        l.fit(&binary_samples()).expect("test: should succeed");
1426        assert_eq!(l.history().len(), 2);
1427    }
1428
1429    #[test]
1430    fn test_history_bounded_at_100() {
1431        let mut l = make_one_feature_learner();
1432        let samples: Vec<DtlSample> = vec![
1433            DtlSample::new(vec![0.0], "A"),
1434            DtlSample::new(vec![1.0], "B"),
1435        ];
1436        for _ in 0..150 {
1437            l.fit(&samples).expect("test: should succeed");
1438        }
1439        assert_eq!(l.history().len(), 100);
1440    }
1441
1442    #[test]
1443    fn test_history_record_correct_n_samples() {
1444        let mut l = make_learner();
1445        l.fit(&binary_samples()).expect("test: should succeed");
1446        assert_eq!(
1447            l.history().back().expect("test: should succeed").n_samples,
1448            5
1449        );
1450    }
1451
1452    #[test]
1453    fn test_history_record_criterion() {
1454        let cfg = DtlLearnerConfig {
1455            criterion: DtlCriterion::Entropy,
1456            ..Default::default()
1457        };
1458        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1459        l.fit(&binary_samples()).expect("test: should succeed");
1460        assert_eq!(
1461            l.history().back().expect("test: should succeed").criterion,
1462            DtlCriterion::Entropy
1463        );
1464    }
1465
1466    // -----------------------------------------------------------------------
1467    // Learner statistics
1468    // -----------------------------------------------------------------------
1469
1470    #[test]
1471    fn test_stats_not_trained() {
1472        let l = make_learner();
1473        let s = l.learner_stats();
1474        assert!(!s.is_trained);
1475        assert_eq!(s.tree_depth, 0);
1476    }
1477
1478    #[test]
1479    fn test_stats_trained() {
1480        let mut l = make_learner();
1481        l.fit(&binary_samples()).expect("test: should succeed");
1482        let s = l.learner_stats();
1483        assert!(s.is_trained);
1484        assert!(s.tree_depth >= 1);
1485        assert_eq!(s.n_classes, 2);
1486        assert_eq!(s.n_features, 2);
1487    }
1488
1489    #[test]
1490    fn test_stats_class_labels_sorted() {
1491        let mut l = make_learner();
1492        l.fit(&binary_samples()).expect("test: should succeed");
1493        let s = l.learner_stats();
1494        let mut sorted = s.class_labels.clone();
1495        sorted.sort();
1496        assert_eq!(s.class_labels, sorted);
1497    }
1498
1499    #[test]
1500    fn test_stats_feature_names_match() {
1501        let mut l = make_learner();
1502        l.fit(&binary_samples()).expect("test: should succeed");
1503        let s = l.learner_stats();
1504        assert_eq!(s.feature_names, vec!["x", "y"]);
1505    }
1506
1507    // -----------------------------------------------------------------------
1508    // Auto feature name generation
1509    // -----------------------------------------------------------------------
1510
1511    #[test]
1512    fn test_auto_feature_names_generated() {
1513        let mut l = DecisionTreeLearner::new(DtlLearnerConfig::default(), vec![]);
1514        l.fit(&binary_samples()).expect("test: should succeed");
1515        let names = l.feature_names();
1516        assert_eq!(names.len(), 2);
1517        assert_eq!(names[0], "f0");
1518        assert_eq!(names[1], "f1");
1519    }
1520
1521    // -----------------------------------------------------------------------
1522    // Feature subsampling (max_features)
1523    // -----------------------------------------------------------------------
1524
1525    #[test]
1526    fn test_max_features_one() {
1527        let cfg = DtlLearnerConfig {
1528            max_features: Some(1),
1529            ..Default::default()
1530        };
1531        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1532        assert!(l.fit(&binary_samples()).is_ok());
1533    }
1534
1535    #[test]
1536    fn test_max_features_all() {
1537        let cfg = DtlLearnerConfig {
1538            max_features: Some(2),
1539            ..Default::default()
1540        };
1541        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1542        assert!(l.fit(&binary_samples()).is_ok());
1543    }
1544
1545    // -----------------------------------------------------------------------
1546    // min_samples_split and min_samples_leaf
1547    // -----------------------------------------------------------------------
1548
1549    #[test]
1550    fn test_min_samples_split_forces_leaf() {
1551        let cfg = DtlLearnerConfig {
1552            min_samples_split: 100,
1553            ..Default::default()
1554        };
1555        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1556        l.fit(&binary_samples()).expect("test: should succeed");
1557        // With min_samples_split=100 and only 5 samples, tree must be a leaf
1558        assert_eq!(l.n_leaves(), 1);
1559    }
1560
1561    #[test]
1562    fn test_min_samples_leaf_respected() {
1563        let cfg = DtlLearnerConfig {
1564            min_samples_leaf: 3,
1565            ..Default::default()
1566        };
1567        let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
1568        l.fit(&binary_samples()).expect("test: should succeed");
1569        // Should still predict without panic
1570        assert!(l.predict(&[1.0, 2.0]).is_ok());
1571    }
1572
1573    // -----------------------------------------------------------------------
1574    // xorshift64 PRNG
1575    // -----------------------------------------------------------------------
1576
1577    #[test]
1578    fn test_xorshift64_deterministic() {
1579        let mut state = 0xdead_beef_cafe_babe_u64;
1580        let a = xorshift64(&mut state);
1581        let mut state2 = 0xdead_beef_cafe_babe_u64;
1582        let b = xorshift64(&mut state2);
1583        assert_eq!(a, b);
1584    }
1585
1586    #[test]
1587    fn test_xorshift64_nonzero() {
1588        let mut state = 1u64;
1589        let v = xorshift64(&mut state);
1590        assert_ne!(v, 0);
1591    }
1592
1593    #[test]
1594    fn test_xorshift64_state_advances() {
1595        let mut state = 12345u64;
1596        let a = xorshift64(&mut state);
1597        let b = xorshift64(&mut state);
1598        assert_ne!(a, b);
1599    }
1600
1601    // -----------------------------------------------------------------------
1602    // select_features
1603    // -----------------------------------------------------------------------
1604
1605    #[test]
1606    fn test_select_features_all() {
1607        let mut rng = 42u64;
1608        let feats = select_features(5, None, &mut rng);
1609        assert_eq!(feats.len(), 5);
1610    }
1611
1612    #[test]
1613    fn test_select_features_limited() {
1614        let mut rng = 42u64;
1615        let feats = select_features(10, Some(3), &mut rng);
1616        assert_eq!(feats.len(), 3);
1617    }
1618
1619    #[test]
1620    fn test_select_features_no_duplicates() {
1621        let mut rng = 999u64;
1622        let feats = select_features(10, Some(10), &mut rng);
1623        let unique: std::collections::HashSet<usize> = feats.iter().copied().collect();
1624        assert_eq!(unique.len(), feats.len());
1625    }
1626
1627    // -----------------------------------------------------------------------
1628    // Impurity helpers
1629    // -----------------------------------------------------------------------
1630
1631    #[test]
1632    fn test_entropy_pure() {
1633        let mut d = HashMap::new();
1634        d.insert("A".to_string(), 10usize);
1635        assert!((entropy(&d, 10) - 0.0).abs() < 1e-9);
1636    }
1637
1638    #[test]
1639    fn test_entropy_balanced_binary() {
1640        let mut d = HashMap::new();
1641        d.insert("A".to_string(), 5usize);
1642        d.insert("B".to_string(), 5usize);
1643        assert!((entropy(&d, 10) - 1.0).abs() < 1e-6);
1644    }
1645
1646    #[test]
1647    fn test_gini_pure() {
1648        let mut d = HashMap::new();
1649        d.insert("A".to_string(), 10usize);
1650        assert!((gini(&d, 10) - 0.0).abs() < 1e-9);
1651    }
1652
1653    #[test]
1654    fn test_gini_balanced_binary() {
1655        let mut d = HashMap::new();
1656        d.insert("A".to_string(), 5usize);
1657        d.insert("B".to_string(), 5usize);
1658        assert!((gini(&d, 10) - 0.5).abs() < 1e-9);
1659    }
1660
1661    #[test]
1662    fn test_misclassification_pure() {
1663        let mut d = HashMap::new();
1664        d.insert("A".to_string(), 10usize);
1665        assert!((misclassification_rate(&d, 10) - 0.0).abs() < 1e-9);
1666    }
1667
1668    #[test]
1669    fn test_misclassification_balanced() {
1670        let mut d = HashMap::new();
1671        d.insert("A".to_string(), 5usize);
1672        d.insert("B".to_string(), 5usize);
1673        assert!((misclassification_rate(&d, 10) - 0.5).abs() < 1e-9);
1674    }
1675
1676    // -----------------------------------------------------------------------
1677    // majority_class
1678    // -----------------------------------------------------------------------
1679
1680    #[test]
1681    fn test_majority_class_clear_winner() {
1682        let mut d = HashMap::new();
1683        d.insert("A".to_string(), 3usize);
1684        d.insert("B".to_string(), 7usize);
1685        assert_eq!(majority_class(&d), "B");
1686    }
1687
1688    #[test]
1689    fn test_majority_class_alphabetical_tiebreak() {
1690        let mut d = HashMap::new();
1691        d.insert("B".to_string(), 5usize);
1692        d.insert("A".to_string(), 5usize);
1693        // Tie broken alphabetically descending (max_by on label reversal)
1694        let m = majority_class(&d);
1695        assert!(m == "A" || m == "B"); // both valid, just no panic
1696    }
1697
1698    // -----------------------------------------------------------------------
1699    // DtlNode tree structure helpers
1700    // -----------------------------------------------------------------------
1701
1702    #[test]
1703    fn test_leaf_n_leaves_is_1() {
1704        let leaf = DtlNode::Leaf {
1705            class_label: "X".into(),
1706            samples: 5,
1707            class_distribution: HashMap::new(),
1708        };
1709        assert_eq!(leaf.n_leaves(), 1);
1710    }
1711
1712    #[test]
1713    fn test_leaf_depth_is_1() {
1714        let leaf = DtlNode::Leaf {
1715            class_label: "X".into(),
1716            samples: 5,
1717            class_distribution: HashMap::new(),
1718        };
1719        assert_eq!(leaf.depth(), 1);
1720    }
1721
1722    #[test]
1723    fn test_split_n_nodes() {
1724        let leaf = || DtlNode::Leaf {
1725            class_label: "X".into(),
1726            samples: 1,
1727            class_distribution: HashMap::new(),
1728        };
1729        let split = DtlNode::Split {
1730            feature_index: 0,
1731            threshold: 0.5,
1732            left: Box::new(leaf()),
1733            right: Box::new(leaf()),
1734            feature_name: "f".into(),
1735            samples: 2,
1736            impurity: 0.5,
1737        };
1738        assert_eq!(split.n_nodes(), 3);
1739    }
1740
1741    // -----------------------------------------------------------------------
1742    // DtlError display
1743    // -----------------------------------------------------------------------
1744
1745    #[test]
1746    fn test_error_display_empty() {
1747        let e = DtlError::EmptyTrainingSet;
1748        assert!(!e.to_string().is_empty());
1749    }
1750
1751    #[test]
1752    fn test_error_display_dim_mismatch() {
1753        let e = DtlError::FeatureDimensionMismatch {
1754            expected: 3,
1755            got: 2,
1756        };
1757        let s = e.to_string();
1758        assert!(s.contains("3") && s.contains("2"));
1759    }
1760
1761    // -----------------------------------------------------------------------
1762    // Refits overwrite previous model
1763    // -----------------------------------------------------------------------
1764
1765    #[test]
1766    fn test_refit_overwrites_tree() {
1767        let mut l = make_learner();
1768        l.fit(&binary_samples()).expect("test: should succeed");
1769        let depth1 = l.depth();
1770
1771        // Refit with a larger dataset
1772        let more: Vec<DtlSample> = (0..20)
1773            .map(|i| {
1774                let label = if i % 2 == 0 { "A" } else { "B" };
1775                DtlSample::new(vec![i as f64, (i * 2) as f64], label)
1776            })
1777            .collect();
1778        l.fit(&more).expect("test: should succeed");
1779        let depth2 = l.depth();
1780        // Just verify it doesn't panic and produces a valid tree
1781        let _ = depth1;
1782        assert!(depth2 >= 1);
1783    }
1784
1785    // -----------------------------------------------------------------------
1786    // Single-sample training
1787    // -----------------------------------------------------------------------
1788
1789    #[test]
1790    fn test_single_sample_fit_and_predict() {
1791        let mut l = make_learner();
1792        let s = vec![DtlSample::new(vec![1.0, 2.0], "Solo")];
1793        l.fit(&s).expect("test: should succeed");
1794        let p = l.predict(&[1.0, 2.0]).expect("test: should succeed");
1795        assert_eq!(p.label, "Solo");
1796        assert!((p.confidence - 1.0).abs() < 1e-9);
1797    }
1798
1799    // -----------------------------------------------------------------------
1800    // Two-sample trivial split
1801    // -----------------------------------------------------------------------
1802
1803    #[test]
1804    fn test_two_samples_different_classes() {
1805        let samples = vec![
1806            DtlSample::new(vec![0.0], "neg"),
1807            DtlSample::new(vec![1.0], "pos"),
1808        ];
1809        let mut l = make_one_feature_learner();
1810        l.fit(&samples).expect("test: should succeed");
1811        assert_eq!(
1812            l.predict(&[0.0]).expect("test: should succeed").label,
1813            "neg"
1814        );
1815        assert_eq!(
1816            l.predict(&[1.0]).expect("test: should succeed").label,
1817            "pos"
1818        );
1819    }
1820
1821    // -----------------------------------------------------------------------
1822    // Serialisation round-trip via serde_json
1823    // -----------------------------------------------------------------------
1824
1825    #[test]
1826    fn test_dtl_sample_serialise() {
1827        let s = DtlSample::new(vec![1.0, 2.0], "A");
1828        let json = serde_json::to_string(&s).expect("test: should succeed");
1829        let back: DtlSample = serde_json::from_str(&json).expect("test: should succeed");
1830        assert_eq!(back.label, "A");
1831        assert_eq!(back.features, vec![1.0, 2.0]);
1832    }
1833
1834    #[test]
1835    fn test_dtl_criterion_serialise() {
1836        let c = DtlCriterion::Entropy;
1837        let json = serde_json::to_string(&c).expect("test: should succeed");
1838        let back: DtlCriterion = serde_json::from_str(&json).expect("test: should succeed");
1839        assert_eq!(back, DtlCriterion::Entropy);
1840    }
1841
1842    #[test]
1843    fn test_dtl_config_serialise() {
1844        let cfg = DtlLearnerConfig {
1845            max_depth: 3,
1846            criterion: DtlCriterion::Gini,
1847            ..Default::default()
1848        };
1849        let json = serde_json::to_string(&cfg).expect("test: should succeed");
1850        let back: DtlLearnerConfig = serde_json::from_str(&json).expect("test: should succeed");
1851        assert_eq!(back.max_depth, 3);
1852        assert_eq!(back.criterion, DtlCriterion::Gini);
1853    }
1854
1855    // -----------------------------------------------------------------------
1856    // class_distribution helper
1857    // -----------------------------------------------------------------------
1858
1859    #[test]
1860    fn test_class_distribution_correct() {
1861        let samples = vec![
1862            DtlSample::new(vec![0.0], "A"),
1863            DtlSample::new(vec![1.0], "A"),
1864            DtlSample::new(vec![2.0], "B"),
1865        ];
1866        let dist = class_distribution(&samples, &[0, 1, 2]);
1867        assert_eq!(dist["A"], 2);
1868        assert_eq!(dist["B"], 1);
1869    }
1870
1871    // -----------------------------------------------------------------------
1872    // node_impurity helper
1873    // -----------------------------------------------------------------------
1874
1875    #[test]
1876    fn test_node_impurity_leaf_is_zero() {
1877        let leaf = DtlNode::Leaf {
1878            class_label: "X".into(),
1879            samples: 5,
1880            class_distribution: HashMap::new(),
1881        };
1882        assert_eq!(node_impurity(&leaf), 0.0);
1883    }
1884
1885    #[test]
1886    fn test_node_impurity_split_nonzero() {
1887        let leaf = || DtlNode::Leaf {
1888            class_label: "X".into(),
1889            samples: 1,
1890            class_distribution: HashMap::new(),
1891        };
1892        let split = DtlNode::Split {
1893            feature_index: 0,
1894            threshold: 0.5,
1895            left: Box::new(leaf()),
1896            right: Box::new(leaf()),
1897            feature_name: "f".into(),
1898            samples: 2,
1899            impurity: 0.42,
1900        };
1901        assert!((node_impurity(&split) - 0.42).abs() < 1e-9);
1902    }
1903}