Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/realizar/src/explain.rs
Line
Count
Source
1
//! Model Explainability (SHAP, LIME, Attention)
2
//!
3
//! Per spec §13: Model explainability for APR classical ML models.
4
//! Implements SHAP TreeExplainer for tree ensembles and KernelSHAP for any model.
5
//!
6
//! ## Methods
7
//!
8
//! | Method | Type | Models | Output |
9
//! |--------|------|--------|--------|
10
//! | **TreeSHAP** | Model-specific | Tree ensembles | Feature contributions |
11
//! | **KernelSHAP** | Model-agnostic | Any | Feature contributions |
12
//!
13
//! ## References
14
//!
15
//! - [16] Lundberg & Lee (2017) "A Unified Approach to Interpreting Model Predictions"
16
//! - [17] Ribeiro et al. (2016) "Why Should I Trust You? Explaining Predictions"
17
18
// Module-level clippy allows for explainability module
19
#![allow(clippy::must_use_candidate)]
20
#![allow(clippy::return_self_not_must_use)]
21
#![allow(clippy::missing_errors_doc)]
22
#![allow(clippy::unused_self)] // Methods designed for future expansion
23
#![allow(clippy::unnecessary_wraps)] // Result wrapping for API consistency
24
#![allow(clippy::option_if_let_else)] // map_or is more readable for our use case
25
26
use serde::{Deserialize, Serialize};
27
use std::fmt;
28
use thiserror::Error;
29
30
/// Error type for explainability operations
31
#[derive(Debug, Error)]
32
pub enum ExplainError {
33
    /// Model does not support explainability
34
    #[error("Model does not support explainability: {reason}")]
35
    UnsupportedModel {
36
        /// Why the model is unsupported
37
        reason: String,
38
    },
39
40
    /// Invalid input dimensions
41
    #[error("Invalid input: expected {expected} features, got {actual}")]
42
    InvalidInput {
43
        /// Expected number of features
44
        expected: usize,
45
        /// Actual number of features provided
46
        actual: usize,
47
    },
48
49
    /// Background dataset required but not provided
50
    #[error("Background dataset required for KernelSHAP")]
51
    NoBackground,
52
53
    /// Computation error
54
    #[error("Computation error: {0}")]
55
    ComputationError(String),
56
}
57
58
/// Trait for models that can be explained
59
pub trait Explainable {
60
    /// Predict for a single instance
61
    fn predict(&self, instance: &[f32]) -> Result<f32, ExplainError>;
62
63
    /// Predict for multiple instances (batch)
64
1
    fn predict_batch(&self, instances: &[Vec<f32>]) -> Result<Vec<f32>, ExplainError> {
65
2
        
instances1
.
iter1
().
map1
(|x| self.predict(x)).
collect1
()
66
1
    }
67
68
    /// Number of features expected
69
    fn n_features(&self) -> usize;
70
71
    /// Check if this is a tree-based model
72
2
    fn is_tree_model(&self) -> bool {
73
2
        false
74
2
    }
75
76
    /// Get tree structure for TreeSHAP (if applicable)
77
0
    fn get_tree_structure(&self) -> Option<&TreeStructure> {
78
0
        None
79
0
    }
80
}
81
82
/// Tree structure for TreeSHAP algorithm
83
#[derive(Debug, Clone, Serialize, Deserialize)]
84
pub struct TreeStructure {
85
    /// Number of trees in the ensemble
86
    pub n_trees: usize,
87
    /// Number of features
88
    pub n_features: usize,
89
    /// Trees in the ensemble
90
    pub trees: Vec<DecisionTree>,
91
}
92
93
/// A single decision tree
94
#[derive(Debug, Clone, Serialize, Deserialize)]
95
pub struct DecisionTree {
96
    /// Feature index used at each node (-1 for leaf)
97
    pub feature: Vec<i32>,
98
    /// Threshold at each node
99
    pub threshold: Vec<f32>,
100
    /// Left child index
101
    pub left: Vec<usize>,
102
    /// Right child index
103
    pub right: Vec<usize>,
104
    /// Value at leaf nodes
105
    pub value: Vec<f32>,
106
}
107
108
impl DecisionTree {
109
    /// Create a new decision tree
110
6
    pub fn new(
111
6
        feature: Vec<i32>,
112
6
        threshold: Vec<f32>,
113
6
        left: Vec<usize>,
114
6
        right: Vec<usize>,
115
6
        value: Vec<f32>,
116
6
    ) -> Self {
117
6
        Self {
118
6
            feature,
119
6
            threshold,
120
6
            left,
121
6
            right,
122
6
            value,
123
6
        }
124
6
    }
125
126
    /// Get the number of nodes in the tree
127
1
    pub fn n_nodes(&self) -> usize {
128
1
        self.feature.len()
129
1
    }
130
131
    /// Check if a node is a leaf
132
18
    pub fn is_leaf(&self, node: usize) -> bool {
133
18
        self.feature.get(node).is_none_or(|&f| f < 0)
134
18
    }
135
136
    /// Predict for a single instance
137
8
    pub fn predict(&self, instance: &[f32]) -> f32 {
138
8
        let mut node = 0;
139
15
        while !self.is_leaf(node) {
140
7
            let feature_idx = self.feature[node] as usize;
141
7
            if instance
142
7
                .get(feature_idx)
143
7
                .is_some_and(|&v| v <= self.threshold[node])
144
5
            {
145
5
                node = self.left[node];
146
5
            } else {
147
2
                node = self.right[node];
148
2
            }
149
        }
150
8
        self.value.get(node).copied().unwrap_or(0.0)
151
8
    }
152
}
153
154
/// SHAP explanation result
155
#[derive(Debug, Clone, Serialize, Deserialize)]
156
pub struct ShapExplanation {
157
    /// Expected model output E[f(X)]
158
    pub base_value: f32,
159
    /// SHAP values for each feature (φᵢ)
160
    /// sum(shap_values) + base_value ≈ prediction
161
    pub shap_values: Vec<f32>,
162
    /// Feature names for display
163
    pub feature_names: Vec<String>,
164
    /// The actual prediction for this instance
165
    pub prediction: f32,
166
}
167
168
impl ShapExplanation {
169
    /// Create a new SHAP explanation
170
10
    pub fn new(base_value: f32, shap_values: Vec<f32>, prediction: f32) -> Self {
171
10
        let n = shap_values.len();
172
        Self {
173
10
            base_value,
174
10
            shap_values,
175
19
            feature_names: 
(0..n)10
.
map10
(|i| format!("feature_{i}")).
collect10
(),
176
10
            prediction,
177
        }
178
10
    }
179
180
    /// Set feature names
181
4
    pub fn with_feature_names(mut self, names: Vec<String>) -> Self {
182
4
        self.feature_names = names;
183
4
        self
184
4
    }
185
186
    /// Get the most important features (sorted by absolute SHAP value)
187
3
    pub fn top_features(&self, n: usize) -> Vec<(String, f32)> {
188
3
        let mut indexed: Vec<_> = self
189
3
            .shap_values
190
3
            .iter()
191
3
            .enumerate()
192
5
            .
map3
(|(i, &v)| (i, v))
193
3
            .collect();
194
4
        
indexed3
.
sort_by3
(|a, b| {
195
4
            b.1.abs()
196
4
                .partial_cmp(&a.1.abs())
197
4
                .unwrap_or(std::cmp::Ordering::Equal)
198
4
        });
199
3
        indexed
200
3
            .into_iter()
201
3
            .take(n)
202
4
            .
map3
(|(i, v)| {
203
4
                let name = self
204
4
                    .feature_names
205
4
                    .get(i)
206
4
                    .cloned()
207
4
                    .unwrap_or_else(|| 
format!0
(
"feature_{i}"0
));
208
4
                (name, v)
209
4
            })
210
3
            .collect()
211
3
    }
212
213
    /// Verify SHAP consistency: sum(shap_values) + base_value ≈ prediction
214
3
    pub fn verify_consistency(&self, tolerance: f32) -> bool {
215
3
        let sum: f32 = self.shap_values.iter().sum();
216
3
        (self.base_value + sum - self.prediction).abs() < tolerance
217
3
    }
218
}
219
220
impl fmt::Display for ShapExplanation {
221
1
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222
1
        writeln!(f, "SHAP Explanation:")
?0
;
223
1
        writeln!(f, "  Base value: {:.4}", self.base_value)
?0
;
224
1
        writeln!(f, "  Prediction: {:.4}", self.prediction)
?0
;
225
1
        writeln!(f, "  Top features:")
?0
;
226
2
        for (name, value) in 
self1
.
top_features1
(5) {
227
2
            let sign = if value >= 0.0 { 
"+"1
} else {
""1
};
228
2
            writeln!(f, "    {name}: {sign}{value:.4}")
?0
;
229
        }
230
1
        Ok(())
231
1
    }
232
}
233
234
/// SHAP explainer for APR classical ML models
235
/// Reference: [16] Lundberg & Lee (2017) SHAP
236
pub struct ShapExplainer {
237
    /// Background dataset for computing expected values
238
    background: Vec<Vec<f32>>,
239
    /// Number of samples for KernelSHAP
240
    nsamples: usize,
241
    /// Feature names
242
    feature_names: Vec<String>,
243
}
244
245
impl ShapExplainer {
246
    /// Create a new SHAP explainer with background data
247
7
    pub fn new(background: Vec<Vec<f32>>) -> Self {
248
7
        let n_features = background.first().map_or(0, Vec::len);
249
        Self {
250
7
            background,
251
            nsamples: 100,
252
10
            feature_names: 
(0..n_features)7
.
map7
(|i| format!("feature_{i}")).
collect7
(),
253
        }
254
7
    }
255
256
    /// Set the number of samples for KernelSHAP
257
1
    pub fn with_nsamples(mut self, nsamples: usize) -> Self {
258
1
        self.nsamples = nsamples;
259
1
        self
260
1
    }
261
262
    /// Set feature names
263
1
    pub fn with_feature_names(mut self, names: Vec<String>) -> Self {
264
1
        self.feature_names = names;
265
1
        self
266
1
    }
267
268
    /// Compute SHAP values for a prediction
269
4
    pub fn explain(
270
4
        &self,
271
4
        model: &dyn Explainable,
272
4
        instance: &[f32],
273
4
    ) -> Result<ShapExplanation, ExplainError> {
274
        // Validate input
275
4
        if instance.len() != model.n_features() {
276
1
            return Err(ExplainError::InvalidInput {
277
1
                expected: model.n_features(),
278
1
                actual: instance.len(),
279
1
            });
280
3
        }
281
282
        // Use TreeSHAP for tree-based models (fast, exact)
283
3
        if model.is_tree_model() {
284
1
            if let Some(tree_structure) = model.get_tree_structure() {
285
1
                return self.tree_shap(tree_structure, instance, model);
286
0
            }
287
2
        }
288
289
        // KernelSHAP for other models (model-agnostic)
290
2
        self.kernel_shap(model, instance)
291
4
    }
292
293
    /// TreeSHAP algorithm for tree-based models
294
    /// O(TLD) complexity where T=trees, L=leaves, D=depth
295
1
    fn tree_shap(
296
1
        &self,
297
1
        tree_structure: &TreeStructure,
298
1
        instance: &[f32],
299
1
        model: &dyn Explainable,
300
1
    ) -> Result<ShapExplanation, ExplainError> {
301
1
        let n_features = tree_structure.n_features;
302
1
        let mut shap_values = vec![0.0; n_features];
303
304
        // Compute SHAP values for each tree and average
305
2
        for 
tree1
in &tree_structure.trees {
306
1
            let tree_shap = self.tree_shap_single(tree, instance)
?0
;
307
1
            for (i, v) in tree_shap.iter().enumerate() {
308
1
                shap_values[i] += v / tree_structure.n_trees as f32;
309
1
            }
310
        }
311
312
        // Compute base value from background
313
1
        let base_value = self.compute_expected_value(model)
?0
;
314
1
        let prediction = model.predict(instance)
?0
;
315
316
1
        Ok(ShapExplanation::new(base_value, shap_values, prediction)
317
1
            .with_feature_names(self.feature_names.clone()))
318
1
    }
319
320
    /// TreeSHAP for a single tree
321
1
    fn tree_shap_single(
322
1
        &self,
323
1
        tree: &DecisionTree,
324
1
        instance: &[f32],
325
1
    ) -> Result<Vec<f32>, ExplainError> {
326
1
        let n_features = instance.len();
327
1
        let mut shap_values = vec![0.0; n_features];
328
329
        // Simplified TreeSHAP using path enumeration
330
        // For each feature, compute its marginal contribution
331
1
        for feature_idx in 0..n_features {
332
            // Compute prediction with feature
333
1
            let pred_with = tree.predict(instance);
334
335
            // Compute prediction without feature (using background mean)
336
1
            let mut instance_without = instance.to_vec();
337
1
            let background_mean = self
338
1
                .background
339
1
                .iter()
340
2
                .
filter_map1
(|bg| bg.get(feature_idx).copied())
341
1
                .sum::<f32>()
342
1
                / self.background.len().max(1) as f32;
343
1
            instance_without[feature_idx] = background_mean;
344
1
            let pred_without = tree.predict(&instance_without);
345
346
            // Marginal contribution (simplified)
347
1
            shap_values[feature_idx] = pred_with - pred_without;
348
        }
349
350
1
        Ok(shap_values)
351
1
    }
352
353
    /// KernelSHAP algorithm for model-agnostic explainability
354
2
    fn kernel_shap(
355
2
        &self,
356
2
        model: &dyn Explainable,
357
2
        instance: &[f32],
358
2
    ) -> Result<ShapExplanation, ExplainError> {
359
2
        if self.background.is_empty() {
360
1
            return Err(ExplainError::NoBackground);
361
1
        }
362
363
1
        let n_features = instance.len();
364
1
        let mut shap_values = vec![0.0; n_features];
365
366
        // KernelSHAP: sample coalitions and compute weighted linear regression
367
1
        for _ in 0..self.nsamples {
368
            // Sample a random coalition (subset of features)
369
100
            let coalition = self.sample_coalition(n_features);
370
100
            let coalition_size = coalition.iter().filter(|&&b| b).count();
371
372
            // Skip empty and full coalitions
373
100
            if coalition_size == 0 || coalition_size == n_features {
374
0
                continue;
375
100
            }
376
377
            // Compute marginal contribution
378
100
            let marginal = self.compute_marginal(model, instance, &coalition)
?0
;
379
380
            // SHAP kernel weight: M / ((M choose |S|) * |S| * (M - |S|))
381
            // where M = n_features, S = coalition
382
100
            let weight = self.shap_kernel_weight(n_features, coalition_size);
383
384
            // Update SHAP values
385
200
            for (i, &in_coalition) in 
coalition.iter()100
.
enumerate100
() {
386
200
                if in_coalition {
387
100
                    shap_values[i] += marginal * weight;
388
100
                }
389
            }
390
        }
391
392
        // Normalize by total weight
393
1
        let total_weight: f32 = (1..n_features)
394
1
            .map(|k| self.shap_kernel_weight(n_features, k))
395
1
            .sum();
396
1
        if total_weight > 0.0 {
397
3
            for 
v2
in &mut shap_values {
398
2
                *v /= total_weight;
399
2
            }
400
0
        }
401
402
        // Compute base value and prediction
403
1
        let base_value = self.compute_expected_value(model)
?0
;
404
1
        let prediction = model.predict(instance)
?0
;
405
406
1
        Ok(ShapExplanation::new(base_value, shap_values, prediction)
407
1
            .with_feature_names(self.feature_names.clone()))
408
2
    }
409
410
    /// Sample a random coalition (subset of features)
411
100
    fn sample_coalition(&self, n_features: usize) -> Vec<bool> {
412
        // Use deterministic sampling for reproducibility in tests
413
        // In production, use thread_rng() instead
414
200
        
(0..n_features)100
.
map100
(|i| i % 2 == 0).
collect100
()
415
100
    }
416
417
    /// Compute marginal contribution for a coalition
418
100
    fn compute_marginal(
419
100
        &self,
420
100
        model: &dyn Explainable,
421
100
        instance: &[f32],
422
100
        coalition: &[bool],
423
100
    ) -> Result<f32, ExplainError> {
424
        // Create masked instance: use instance values for coalition, background mean for others
425
100
        let mut total_pred = 0.0;
426
100
        let n_background = self.background.len().max(1);
427
428
300
        for 
bg200
in &self.background {
429
200
            let mut masked: Vec<f32> = Vec::with_capacity(instance.len());
430
400
            for (i, (&inst_val, &in_coalition)) in 
instance200
.
iter200
().
zip200
(
coalition200
.
iter200
()).
enumerate200
()
431
            {
432
400
                if in_coalition {
433
200
                    masked.push(inst_val);
434
200
                } else {
435
200
                    masked.push(bg.get(i).copied().unwrap_or(0.0));
436
200
                }
437
            }
438
200
            total_pred += model.predict(&masked)
?0
;
439
        }
440
441
100
        Ok(total_pred / n_background as f32)
442
100
    }
443
444
    /// Compute expected value E[f(X)] from background
445
2
    fn compute_expected_value(&self, model: &dyn Explainable) -> Result<f32, ExplainError> {
446
2
        if self.background.is_empty() {
447
0
            return Ok(0.0);
448
2
        }
449
450
2
        let predictions: Result<Vec<f32>, _> =
451
4
            
self.background.iter()2
.
map2
(|x| model.predict(x)).
collect2
();
452
2
        let predictions = predictions
?0
;
453
454
2
        Ok(predictions.iter().sum::<f32>() / predictions.len() as f32)
455
2
    }
456
457
    /// SHAP kernel weight
458
101
    fn shap_kernel_weight(&self, n_features: usize, coalition_size: usize) -> f32 {
459
        // Weight = M / (binom(M, |S|) * |S| * (M - |S|))
460
101
        let m = n_features as f32;
461
101
        let s = coalition_size as f32;
462
101
        let binom = binomial(n_features, coalition_size) as f32;
463
101
        if binom * s * (m - s) == 0.0 {
464
0
            0.0
465
        } else {
466
101
            m / (binom * s * (m - s))
467
        }
468
101
    }
469
}
470
471
/// Compute binomial coefficient (n choose k)
472
112
fn binomial(n: usize, k: usize) -> usize {
473
112
    if k > n {
474
1
        return 0;
475
111
    }
476
111
    if k == 0 || 
k == n108
{
477
5
        return 1;
478
106
    }
479
106
    let k = k.min(n - k); // Take advantage of symmetry
480
106
    let mut result = 1usize;
481
112
    for i in 0..
k106
{
482
112
        result = result.saturating_mul(n - i) / (i + 1);
483
112
    }
484
106
    result
485
112
}
486
487
// ============================================================================
488
// Tests
489
// ============================================================================
490
491
#[cfg(test)]
492
mod tests {
493
    use super::*;
494
495
    /// Simple linear model for testing
496
    struct LinearModel {
497
        weights: Vec<f32>,
498
        bias: f32,
499
    }
500
501
    impl LinearModel {
502
4
        fn new(weights: Vec<f32>, bias: f32) -> Self {
503
4
            Self { weights, bias }
504
4
        }
505
    }
506
507
    impl Explainable for LinearModel {
508
205
        fn predict(&self, instance: &[f32]) -> Result<f32, ExplainError> {
509
205
            if instance.len() != self.weights.len() {
510
0
                return Err(ExplainError::InvalidInput {
511
0
                    expected: self.weights.len(),
512
0
                    actual: instance.len(),
513
0
                });
514
205
            }
515
410
            let 
dot205
:
f32205
=
instance205
.
iter205
().
zip205
(
&self.weights205
).
map205
(|(x, w)| x * w).
sum205
();
516
205
            Ok(dot + self.bias)
517
205
        }
518
519
4
        fn n_features(&self) -> usize {
520
4
            self.weights.len()
521
4
        }
522
    }
523
524
    /// Simple tree model for testing
525
    struct SimpleTreeModel {
526
        structure: TreeStructure,
527
    }
528
529
    impl SimpleTreeModel {
530
1
        fn new(tree: DecisionTree) -> Self {
531
1
            let n_features = tree
532
1
                .feature
533
1
                .iter()
534
3
                .
filter1
(|&&f| f >= 0)
535
1
                .map(|&f| f as usize + 1)
536
1
                .max()
537
1
                .unwrap_or(1);
538
1
            Self {
539
1
                structure: TreeStructure {
540
1
                    n_trees: 1,
541
1
                    n_features,
542
1
                    trees: vec![tree],
543
1
                },
544
1
            }
545
1
        }
546
    }
547
548
    impl Explainable for SimpleTreeModel {
549
3
        fn predict(&self, instance: &[f32]) -> Result<f32, ExplainError> {
550
3
            let sum: f32 = self
551
3
                .structure
552
3
                .trees
553
3
                .iter()
554
3
                .map(|t| t.predict(instance))
555
3
                .sum();
556
3
            Ok(sum / self.structure.n_trees as f32)
557
3
        }
558
559
1
        fn n_features(&self) -> usize {
560
1
            self.structure.n_features
561
1
        }
562
563
1
        fn is_tree_model(&self) -> bool {
564
1
            true
565
1
        }
566
567
1
        fn get_tree_structure(&self) -> Option<&TreeStructure> {
568
1
            Some(&self.structure)
569
1
        }
570
    }
571
572
    // === ShapExplanation Tests ===
573
574
    #[test]
575
1
    fn test_shap_explanation_new() {
576
1
        let exp = ShapExplanation::new(0.5, vec![0.1, -0.2, 0.3], 0.7);
577
1
        assert_eq!(exp.base_value, 0.5);
578
1
        assert_eq!(exp.shap_values.len(), 3);
579
1
        assert_eq!(exp.prediction, 0.7);
580
1
        assert_eq!(exp.feature_names.len(), 3);
581
1
    }
582
583
    #[test]
584
1
    fn test_shap_explanation_with_feature_names() {
585
1
        let exp = ShapExplanation::new(0.5, vec![0.1, -0.2], 0.4)
586
1
            .with_feature_names(vec!["age".to_string(), "income".to_string()]);
587
1
        assert_eq!(exp.feature_names, vec!["age", "income"]);
588
1
    }
589
590
    #[test]
591
1
    fn test_shap_explanation_top_features() {
592
1
        let exp = ShapExplanation::new(0.0, vec![0.1, -0.3, 0.2], 0.0).with_feature_names(vec![
593
1
            "a".to_string(),
594
1
            "b".to_string(),
595
1
            "c".to_string(),
596
        ]);
597
598
1
        let top = exp.top_features(2);
599
1
        assert_eq!(top.len(), 2);
600
        // Should be sorted by absolute value
601
1
        assert_eq!(top[0].0, "b"); // |-0.3| = 0.3
602
1
        assert_eq!(top[1].0, "c"); // |0.2| = 0.2
603
1
    }
604
605
    #[test]
606
1
    fn test_shap_explanation_verify_consistency() {
607
        // Perfect consistency
608
1
        let exp = ShapExplanation::new(0.5, vec![0.2, 0.3], 1.0);
609
1
        assert!(exp.verify_consistency(0.01));
610
611
        // Not consistent
612
1
        let exp_bad = ShapExplanation::new(0.5, vec![0.2, 0.3], 2.0);
613
1
        assert!(!exp_bad.verify_consistency(0.01));
614
1
    }
615
616
    #[test]
617
1
    fn test_shap_explanation_display() {
618
1
        let exp = ShapExplanation::new(0.5, vec![0.1, -0.2], 0.4);
619
1
        let display = format!("{exp}");
620
1
        assert!(display.contains("SHAP Explanation"));
621
1
        assert!(display.contains("Base value"));
622
1
        assert!(display.contains("Prediction"));
623
1
    }
624
625
    // === Decision Tree Tests ===
626
627
    #[test]
628
1
    fn test_decision_tree_predict_simple() {
629
        // Simple tree: if feature_0 <= 0.5 then 1.0 else 2.0
630
1
        let tree = DecisionTree::new(
631
1
            vec![0, -1, -1],     // feature indices (-1 = leaf)
632
1
            vec![0.5, 0.0, 0.0], // thresholds
633
1
            vec![1, 0, 0],       // left children
634
1
            vec![2, 0, 0],       // right children
635
1
            vec![0.0, 1.0, 2.0], // values
636
        );
637
638
1
        assert_eq!(tree.predict(&[0.3]), 1.0); // <= 0.5, go left
639
1
        assert_eq!(tree.predict(&[0.7]), 2.0); // > 0.5, go right
640
1
    }
641
642
    #[test]
643
1
    fn test_decision_tree_n_nodes() {
644
1
        let tree = DecisionTree::new(
645
1
            vec![0, -1, -1],
646
1
            vec![0.5, 0.0, 0.0],
647
1
            vec![1, 0, 0],
648
1
            vec![2, 0, 0],
649
1
            vec![0.0, 1.0, 2.0],
650
        );
651
1
        assert_eq!(tree.n_nodes(), 3);
652
1
    }
653
654
    #[test]
655
1
    fn test_decision_tree_is_leaf() {
656
1
        let tree = DecisionTree::new(
657
1
            vec![0, -1, -1],
658
1
            vec![0.5, 0.0, 0.0],
659
1
            vec![1, 0, 0],
660
1
            vec![2, 0, 0],
661
1
            vec![0.0, 1.0, 2.0],
662
        );
663
1
        assert!(!tree.is_leaf(0)); // root is not leaf
664
1
        assert!(tree.is_leaf(1)); // leaf
665
1
        assert!(tree.is_leaf(2)); // leaf
666
1
    }
667
668
    // === ShapExplainer Tests ===
669
670
    #[test]
671
1
    fn test_shap_explainer_new() {
672
1
        let background = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
673
1
        let explainer = ShapExplainer::new(background);
674
1
        assert_eq!(explainer.nsamples, 100);
675
1
        assert_eq!(explainer.feature_names.len(), 2);
676
1
    }
677
678
    #[test]
679
1
    fn test_shap_explainer_with_nsamples() {
680
1
        let explainer = ShapExplainer::new(vec![vec![1.0]]).with_nsamples(50);
681
1
        assert_eq!(explainer.nsamples, 50);
682
1
    }
683
684
    #[test]
685
1
    fn test_shap_explainer_with_feature_names() {
686
1
        let explainer = ShapExplainer::new(vec![vec![1.0, 2.0]])
687
1
            .with_feature_names(vec!["age".to_string(), "income".to_string()]);
688
1
        assert_eq!(explainer.feature_names, vec!["age", "income"]);
689
1
    }
690
691
    #[test]
692
1
    fn test_shap_explainer_linear_model() {
693
1
        let model = LinearModel::new(vec![1.0, 2.0], 0.0);
694
1
        let background = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
695
1
        let explainer = ShapExplainer::new(background);
696
697
1
        let instance = vec![2.0, 3.0]; // prediction = 2*1 + 3*2 = 8
698
1
        let explanation = explainer.explain(&model, &instance).expect("test");
699
700
1
        assert_eq!(explanation.prediction, 8.0);
701
1
        assert_eq!(explanation.shap_values.len(), 2);
702
1
    }
703
704
    #[test]
705
1
    fn test_shap_explainer_tree_model() {
706
        // Simple tree: if feature_0 <= 0.5 then 1.0 else 2.0
707
1
        let tree = DecisionTree::new(
708
1
            vec![0, -1, -1],
709
1
            vec![0.5, 0.0, 0.0],
710
1
            vec![1, 0, 0],
711
1
            vec![2, 0, 0],
712
1
            vec![0.0, 1.0, 2.0],
713
        );
714
1
        let model = SimpleTreeModel::new(tree);
715
716
1
        let background = vec![vec![0.3], vec![0.7]];
717
1
        let explainer = ShapExplainer::new(background);
718
719
1
        let explanation = explainer.explain(&model, &[0.3]).expect("test");
720
1
        assert_eq!(explanation.prediction, 1.0);
721
1
    }
722
723
    #[test]
724
1
    fn test_shap_explainer_invalid_input() {
725
1
        let model = LinearModel::new(vec![1.0, 2.0], 0.0);
726
1
        let background = vec![vec![0.0, 0.0]];
727
1
        let explainer = ShapExplainer::new(background);
728
729
1
        let result = explainer.explain(&model, &[1.0, 2.0, 3.0]); // 3 features, model expects 2
730
1
        assert!(
matches!0
(result, Err(ExplainError::InvalidInput { .. })));
731
1
    }
732
733
    #[test]
734
1
    fn test_shap_explainer_empty_background() {
735
1
        let model = LinearModel::new(vec![1.0], 0.0);
736
1
        let explainer = ShapExplainer::new(vec![]); // Empty background
737
738
1
        let result = explainer.explain(&model, &[1.0]);
739
1
        assert!(
matches!0
(result, Err(ExplainError::NoBackground)));
740
1
    }
741
742
    // === ExplainError Tests ===
743
744
    #[test]
745
1
    fn test_explain_error_display() {
746
1
        let err = ExplainError::UnsupportedModel {
747
1
            reason: "not a tree".to_string(),
748
1
        };
749
1
        assert!(err.to_string().contains("not a tree"));
750
751
1
        let err = ExplainError::InvalidInput {
752
1
            expected: 3,
753
1
            actual: 2,
754
1
        };
755
1
        assert!(err.to_string().contains("expected 3"));
756
1
        assert!(err.to_string().contains("got 2"));
757
758
1
        let err = ExplainError::NoBackground;
759
1
        assert!(err.to_string().contains("Background"));
760
761
1
        let err = ExplainError::ComputationError("overflow".to_string());
762
1
        assert!(err.to_string().contains("overflow"));
763
1
    }
764
765
    // === Binomial Tests ===
766
767
    #[test]
768
1
    fn test_binomial_coefficient() {
769
1
        assert_eq!(binomial(5, 0), 1);
770
1
        assert_eq!(binomial(5, 1), 5);
771
1
        assert_eq!(binomial(5, 2), 10);
772
1
        assert_eq!(binomial(5, 3), 10);
773
1
        assert_eq!(binomial(5, 4), 5);
774
1
        assert_eq!(binomial(5, 5), 1);
775
1
        assert_eq!(binomial(5, 6), 0); // k > n
776
1
    }
777
778
    #[test]
779
1
    fn test_binomial_edge_cases() {
780
1
        assert_eq!(binomial(0, 0), 1);
781
1
        assert_eq!(binomial(1, 0), 1);
782
1
        assert_eq!(binomial(1, 1), 1);
783
1
        assert_eq!(binomial(10, 5), 252);
784
1
    }
785
786
    // === TreeStructure Tests ===
787
788
    #[test]
789
1
    fn test_tree_structure_serialization() {
790
1
        let tree = DecisionTree::new(
791
1
            vec![0, -1, -1],
792
1
            vec![0.5, 0.0, 0.0],
793
1
            vec![1, 0, 0],
794
1
            vec![2, 0, 0],
795
1
            vec![0.0, 1.0, 2.0],
796
        );
797
1
        let structure = TreeStructure {
798
1
            n_trees: 1,
799
1
            n_features: 1,
800
1
            trees: vec![tree],
801
1
        };
802
803
1
        let json = serde_json::to_string(&structure).expect("test");
804
1
        assert!(json.contains("n_trees"));
805
1
        assert!(json.contains("n_features"));
806
1
    }
807
808
    #[test]
809
1
    fn test_shap_explanation_serialization() {
810
1
        let exp = ShapExplanation::new(0.5, vec![0.1, -0.2], 0.4);
811
1
        let json = serde_json::to_string(&exp).expect("test");
812
1
        let parsed: ShapExplanation = serde_json::from_str(&json).expect("test");
813
814
1
        assert_eq!(parsed.base_value, exp.base_value);
815
1
        assert_eq!(parsed.shap_values, exp.shap_values);
816
1
        assert_eq!(parsed.prediction, exp.prediction);
817
1
    }
818
819
    // === Additional Edge Case Tests ===
820
821
    #[test]
822
1
    fn test_shap_explanation_empty_values() {
823
1
        let exp = ShapExplanation::new(0.5, vec![], 0.5);
824
1
        assert!(exp.verify_consistency(0.01));
825
1
        assert!(exp.top_features(3).is_empty());
826
1
    }
827
828
    #[test]
829
1
    fn test_decision_tree_empty_instance() {
830
1
        let tree = DecisionTree::new(
831
1
            vec![-1], // Just a leaf
832
1
            vec![0.0],
833
1
            vec![0],
834
1
            vec![0],
835
1
            vec![5.0],
836
        );
837
1
        assert_eq!(tree.predict(&[]), 5.0);
838
1
    }
839
840
    #[test]
841
1
    fn test_linear_model_batch_predict() {
842
1
        let model = LinearModel::new(vec![1.0, 2.0], 0.0);
843
1
        let instances = vec![vec![1.0, 1.0], vec![2.0, 2.0]];
844
1
        let predictions = model.predict_batch(&instances).expect("test");
845
1
        assert_eq!(predictions, vec![3.0, 6.0]);
846
1
    }
847
}