1use std::collections::{HashMap, VecDeque};
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21#[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#[derive(Debug, Error, Serialize, Deserialize, Clone, PartialEq)]
41pub enum DtlError {
42 #[error("training set is empty")]
44 EmptyTrainingSet,
45
46 #[error("expected {expected} features, got {got}")]
48 FeatureDimensionMismatch { expected: usize, got: usize },
49
50 #[error("model has not been trained yet")]
52 ModelNotTrained,
53
54 #[error("unknown class label: {0}")]
56 UnknownLabel(String),
57
58 #[error("feature names count {names} does not match feature dimension {dim}")]
60 FeatureNamesMismatch { names: usize, dim: usize },
61
62 #[error("arithmetic error: {0}")]
64 Arithmetic(String),
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
73pub enum DtlCriterion {
74 Entropy,
76 #[default]
78 Gini,
79 MisclassificationRate,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DtlLearnerConfig {
86 pub max_depth: usize,
88 pub min_samples_split: usize,
90 pub min_samples_leaf: usize,
92 pub criterion: DtlCriterion,
94 pub max_features: Option<usize>,
96 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#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DtlSample {
120 pub features: Vec<f64>,
122 pub label: String,
124}
125
126impl DtlSample {
127 pub fn new(features: Vec<f64>, label: impl Into<String>) -> Self {
129 Self {
130 features,
131 label: label.into(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct DtlPrediction {
139 pub label: String,
141 pub confidence: f64,
143 pub path_depth: usize,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
153pub enum DtlNode {
154 Leaf {
156 class_label: String,
158 samples: usize,
160 class_distribution: HashMap<String, usize>,
162 },
163 Split {
165 feature_index: usize,
167 threshold: f64,
169 left: Box<DtlNode>,
171 right: Box<DtlNode>,
173 feature_name: String,
175 samples: usize,
177 impurity: f64,
179 },
180}
181
182impl DtlNode {
183 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 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 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 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
246fn node_impurity(node: &DtlNode) -> f64 {
248 match node {
249 DtlNode::Leaf { .. } => 0.0,
250 DtlNode::Split { impurity, .. } => *impurity,
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct DtlTrainingRecord {
261 pub timestamp_secs: u64,
263 pub n_samples: usize,
265 pub n_features: usize,
267 pub n_classes: usize,
269 pub tree_depth: usize,
271 pub n_leaves: usize,
273 pub criterion: DtlCriterion,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct DtlLearnerStats {
284 pub is_trained: bool,
286 pub last_n_samples: usize,
288 pub n_features: usize,
290 pub n_classes: usize,
292 pub tree_depth: usize,
294 pub n_leaves: usize,
296 pub n_nodes: usize,
298 pub history_len: usize,
300 pub criterion: DtlCriterion,
302 pub feature_names: Vec<String>,
304 pub class_labels: Vec<String>,
306}
307
308pub struct DecisionTreeLearner {
335 root: Option<DtlNode>,
337 feature_names: Vec<String>,
339 class_labels: Vec<String>,
341 history: VecDeque<DtlTrainingRecord>,
343 config: DtlLearnerConfig,
345 n_features: usize,
347 last_n_samples: usize,
349}
350
351pub type DtlDecisionTreeLearner = DecisionTreeLearner;
357
358impl DecisionTreeLearner {
365 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 pub fn with_defaults(feature_names: Vec<String>) -> Self {
383 Self::new(DtlLearnerConfig::default(), feature_names)
384 }
385}
386
387impl DecisionTreeLearner {
392 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 if self.feature_names.is_empty() {
410 self.feature_names = (0..n_features).map(|i| format!("f{i}")).collect();
411 }
412
413 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 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 fn build_node(
454 &self,
455 samples: &[DtlSample],
456 indices: &[usize],
457 depth: usize,
458 rng: &mut u64,
459 ) -> DtlNode {
460 let distribution = class_distribution(samples, indices);
462 let majority = majority_class(&distribution);
463 let n = indices.len();
464
465 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 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 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
527impl DecisionTreeLearner {
532 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 pub fn predict_batch(&self, samples: &[Vec<f64>]) -> Vec<Result<DtlPrediction, DtlError>> {
546 samples.iter().map(|f| self.predict(f)).collect()
547 }
548}
549
550fn 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
587impl DecisionTreeLearner {
592 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
623impl DecisionTreeLearner {
628 pub fn depth(&self) -> usize {
630 self.root.as_ref().map_or(0, |r| r.depth())
631 }
632
633 pub fn n_leaves(&self) -> usize {
635 self.root.as_ref().map_or(0, |r| r.n_leaves())
636 }
637
638 pub fn n_nodes(&self) -> usize {
640 self.root.as_ref().map_or(0, |r| r.n_nodes())
641 }
642}
643
644impl DecisionTreeLearner {
649 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 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 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
741impl DecisionTreeLearner {
746 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 pub fn history(&self) -> &VecDeque<DtlTrainingRecord> {
765 &self.history
766 }
767
768 pub fn config(&self) -> &DtlLearnerConfig {
770 &self.config
771 }
772
773 pub fn feature_names(&self) -> &[String] {
775 &self.feature_names
776 }
777
778 pub fn class_labels(&self) -> &[String] {
780 &self.class_labels
781 }
782
783 pub fn root(&self) -> Option<&DtlNode> {
785 self.root.as_ref()
786 }
787}
788
789fn 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
804fn 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
812fn 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
850struct SplitCandidate {
855 feature_index: usize,
856 threshold: f64,
857 gain: f64,
858}
859
860fn 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 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 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 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 if left_n < min_samples_leaf || right_n < min_samples_leaf {
908 continue;
909 }
910
911 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
946fn 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 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
963fn 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#[cfg(test)]
980mod tests {
981 use super::*;
982
983 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 #[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 #[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 #[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 #[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 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 let results = l.predict_batch(&[vec![1.0]]);
1199 assert!(results[0].is_err());
1200 }
1201
1202 #[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 #[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); }
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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 assert!(l.predict(&[1.0, 2.0]).is_ok());
1571 }
1572
1573 #[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 #[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 #[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 #[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 let m = majority_class(&d);
1695 assert!(m == "A" || m == "B"); }
1697
1698 #[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 #[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 #[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 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 let _ = depth1;
1782 assert!(depth2 >= 1);
1783 }
1784
1785 #[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 #[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 #[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 #[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 #[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}