Skip to main content

quantrs2_ml/anomaly_detection/algorithms/
isolation_forest.rs

1//! Quantum Isolation Forest implementation
2
3use crate::error::{MLError, Result};
4use scirs2_core::ndarray::{Array1, Array2, Axis};
5use scirs2_core::random::prelude::*;
6use scirs2_core::random::Rng;
7use std::collections::HashMap;
8
9use super::super::config::*;
10use super::super::core::AnomalyDetectorTrait;
11use super::super::metrics::*;
12
13/// Quantum Isolation Forest implementation
14#[derive(Debug)]
15pub struct QuantumIsolationForest {
16    config: QuantumAnomalyConfig,
17    trees: Vec<QuantumIsolationTree>,
18    feature_stats: Option<Array2<f64>>,
19}
20
21/// Quantum Isolation Tree
22#[derive(Debug)]
23pub struct QuantumIsolationTree {
24    root: Option<QuantumIsolationNode>,
25    max_depth: usize,
26    quantum_splitting: bool,
27}
28
29/// Quantum Isolation Tree Node
30#[derive(Debug)]
31pub struct QuantumIsolationNode {
32    split_feature: usize,
33    split_value: f64,
34    left: Option<Box<QuantumIsolationNode>>,
35    right: Option<Box<QuantumIsolationNode>>,
36    depth: usize,
37    size: usize,
38    quantum_split: bool,
39}
40
41impl QuantumIsolationForest {
42    /// Create new quantum isolation forest
43    pub fn new(config: QuantumAnomalyConfig) -> Result<Self> {
44        Ok(QuantumIsolationForest {
45            config,
46            trees: Vec::new(),
47            feature_stats: None,
48        })
49    }
50
51    /// Build isolation trees
52    fn build_trees(&mut self, data: &Array2<f64>) -> Result<()> {
53        if let AnomalyDetectionMethod::QuantumIsolationForest {
54            n_estimators,
55            max_samples,
56            max_depth,
57            quantum_splitting,
58        } = &self.config.primary_method
59        {
60            self.trees.clear();
61
62            for _ in 0..*n_estimators {
63                let tree = QuantumIsolationTree::new(*max_depth, *quantum_splitting);
64                self.trees.push(tree);
65            }
66
67            // Train each tree on a random subsample
68            for tree in &mut self.trees {
69                let subsample = Self::create_subsample_static(data, *max_samples)?;
70                tree.fit(&subsample)?;
71            }
72        }
73
74        Ok(())
75    }
76
77    /// Create random subsample (static version)
78    fn create_subsample_static(data: &Array2<f64>, max_samples: usize) -> Result<Array2<f64>> {
79        let n_samples = data.nrows().min(max_samples);
80        let mut indices: Vec<usize> = (0..data.nrows()).collect();
81
82        // Shuffle indices
83        for i in 0..indices.len() {
84            let j = thread_rng().random_range(0..indices.len());
85            indices.swap(i, j);
86        }
87
88        indices.truncate(n_samples);
89        let subsample = data.select(Axis(0), &indices);
90        Ok(subsample)
91    }
92
93    /// Compute anomaly scores
94    fn compute_scores(&self, data: &Array2<f64>) -> Result<Array1<f64>> {
95        let n_samples = data.nrows();
96        let mut scores = Array1::zeros(n_samples);
97
98        for i in 0..n_samples {
99            let sample = data.row(i);
100            let mut path_lengths = Vec::new();
101
102            for tree in &self.trees {
103                let path_length = tree.path_length(&sample.to_owned())?;
104                path_lengths.push(path_length);
105            }
106
107            let avg_path_length = path_lengths.iter().sum::<f64>() / path_lengths.len() as f64;
108            let c_n = self.compute_c_value(n_samples);
109            scores[i] = 2.0_f64.powf(-avg_path_length / c_n);
110        }
111
112        Ok(scores)
113    }
114
115    /// Compute c(n) value for isolation forest normalization
116    fn compute_c_value(&self, n: usize) -> f64 {
117        if n <= 1 {
118            return 1.0;
119        }
120        2.0 * (n as f64 - 1.0).ln() - 2.0 * (n - 1) as f64 / n as f64
121    }
122
123    /// Compute threshold based on contamination level
124    fn compute_threshold(&self, scores: &Array1<f64>) -> Result<f64> {
125        let mut sorted_scores: Vec<f64> = scores.iter().cloned().collect();
126        sorted_scores.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
127
128        let contamination_index = (sorted_scores.len() as f64 * self.config.contamination) as usize;
129        let threshold = if contamination_index < sorted_scores.len() {
130            sorted_scores[contamination_index]
131        } else {
132            sorted_scores[sorted_scores.len() - 1]
133        };
134
135        Ok(threshold)
136    }
137
138    /// `AnomalyMetrics`/`QuantumAnomalyMetrics` with every field set to
139    /// `f64::NAN`, for use where no ground truth (or no real quantum
140    /// circuit execution) is available to honestly back a value.
141    fn not_computed_metrics() -> AnomalyMetrics {
142        AnomalyMetrics {
143            auc_roc: f64::NAN,
144            auc_pr: f64::NAN,
145            precision: f64::NAN,
146            recall: f64::NAN,
147            f1_score: f64::NAN,
148            false_positive_rate: f64::NAN,
149            false_negative_rate: f64::NAN,
150            mcc: f64::NAN,
151            balanced_accuracy: f64::NAN,
152            quantum_metrics: QuantumAnomalyMetrics {
153                quantum_advantage: f64::NAN,
154                entanglement_utilization: f64::NAN,
155                circuit_efficiency: f64::NAN,
156                quantum_error_rate: f64::NAN,
157                coherence_utilization: f64::NAN,
158            },
159        }
160    }
161
162    /// Evaluate detection performance against ground-truth labels.
163    ///
164    /// Unlike `detect()` (which has no access to labels and therefore cannot
165    /// honestly report supervised metrics), this computes real
166    /// confusion-matrix-derived precision/recall/F1/MCC/balanced-accuracy/
167    /// false-positive-and-negative rates, plus rank-based AUC-ROC and a
168    /// precision-recall-curve AUC-PR, from the model's actual anomaly scores
169    /// and predicted labels versus `true_labels` (`1` = anomaly, `0` =
170    /// normal), mirroring the pattern used by `clustering::core`'s
171    /// `evaluate`. `quantum_metrics` remain `NaN` (see
172    /// [`Self::not_computed_metrics`]): this classical implementation has no
173    /// real circuit-execution statistics to report.
174    pub fn evaluate(
175        &self,
176        data: &Array2<f64>,
177        true_labels: &Array1<i32>,
178    ) -> Result<AnomalyMetrics> {
179        if data.nrows() != true_labels.len() {
180            return Err(MLError::InvalidInput(format!(
181                "true_labels length {} does not match number of samples {}",
182                true_labels.len(),
183                data.nrows()
184            )));
185        }
186        if data.nrows() == 0 {
187            return Err(MLError::InvalidInput("Empty data".to_string()));
188        }
189
190        let anomaly_scores = self.compute_scores(data)?;
191        let threshold = self.compute_threshold(&anomaly_scores)?;
192        let predicted_labels: Vec<i32> = anomaly_scores
193            .iter()
194            .map(|&score| if score > threshold { 1 } else { 0 })
195            .collect();
196
197        let mut true_positive = 0.0_f64;
198        let mut false_positive = 0.0_f64;
199        let mut true_negative = 0.0_f64;
200        let mut false_negative = 0.0_f64;
201        for (&predicted, &truth) in predicted_labels.iter().zip(true_labels.iter()) {
202            match (predicted > 0, truth > 0) {
203                (true, true) => true_positive += 1.0,
204                (true, false) => false_positive += 1.0,
205                (false, true) => false_negative += 1.0,
206                (false, false) => true_negative += 1.0,
207            }
208        }
209
210        let precision = if true_positive + false_positive > 0.0 {
211            true_positive / (true_positive + false_positive)
212        } else {
213            0.0
214        };
215        let recall = if true_positive + false_negative > 0.0 {
216            true_positive / (true_positive + false_negative)
217        } else {
218            0.0
219        };
220        let f1_score = if precision + recall > 0.0 {
221            2.0 * precision * recall / (precision + recall)
222        } else {
223            0.0
224        };
225        let false_positive_rate = if false_positive + true_negative > 0.0 {
226            false_positive / (false_positive + true_negative)
227        } else {
228            0.0
229        };
230        let false_negative_rate = if false_negative + true_positive > 0.0 {
231            false_negative / (false_negative + true_positive)
232        } else {
233            0.0
234        };
235        let specificity = if true_negative + false_positive > 0.0 {
236            true_negative / (true_negative + false_positive)
237        } else {
238            0.0
239        };
240        let balanced_accuracy = (recall + specificity) / 2.0;
241
242        let mcc_denominator = ((true_positive + false_positive)
243            * (true_positive + false_negative)
244            * (true_negative + false_positive)
245            * (true_negative + false_negative))
246            .sqrt();
247        let mcc = if mcc_denominator > 0.0 {
248            (true_positive * true_negative - false_positive * false_negative) / mcc_denominator
249        } else {
250            0.0
251        };
252
253        let auc_roc = Self::compute_auc_roc(&anomaly_scores, true_labels);
254        let auc_pr = Self::compute_auc_pr(&anomaly_scores, true_labels);
255
256        Ok(AnomalyMetrics {
257            auc_roc,
258            auc_pr,
259            precision,
260            recall,
261            f1_score,
262            false_positive_rate,
263            false_negative_rate,
264            mcc,
265            balanced_accuracy,
266            quantum_metrics: QuantumAnomalyMetrics {
267                quantum_advantage: f64::NAN,
268                entanglement_utilization: f64::NAN,
269                circuit_efficiency: f64::NAN,
270                quantum_error_rate: f64::NAN,
271                coherence_utilization: f64::NAN,
272            },
273        })
274    }
275
276    /// Real AUC-ROC via the rank-sum (Mann-Whitney U) formulation: rank all
277    /// scores ascending (averaging ranks for ties), then
278    /// `AUC = (sum of positive-class ranks - n_pos*(n_pos+1)/2) / (n_pos*n_neg)`.
279    fn compute_auc_roc(scores: &Array1<f64>, true_labels: &Array1<i32>) -> f64 {
280        let n_pos = true_labels.iter().filter(|&&l| l > 0).count();
281        let n_neg = true_labels.len() - n_pos;
282        if n_pos == 0 || n_neg == 0 {
283            return f64::NAN;
284        }
285
286        let mut indexed: Vec<(usize, f64)> = scores.iter().cloned().enumerate().collect();
287        indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
288
289        let mut ranks = vec![0.0_f64; indexed.len()];
290        let mut i = 0;
291        while i < indexed.len() {
292            let mut j = i;
293            while j + 1 < indexed.len() && indexed[j + 1].1 == indexed[i].1 {
294                j += 1;
295            }
296            // Average rank (1-indexed) for the tied group [i, j].
297            let average_rank = ((i + 1) + (j + 1)) as f64 / 2.0;
298            for item in indexed.iter().take(j + 1).skip(i) {
299                ranks[item.0] = average_rank;
300            }
301            i = j + 1;
302        }
303
304        let rank_sum_positive: f64 = true_labels
305            .iter()
306            .enumerate()
307            .filter(|(_, &label)| label > 0)
308            .map(|(idx, _)| ranks[idx])
309            .sum();
310
311        let n_pos_f = n_pos as f64;
312        let n_neg_f = n_neg as f64;
313        (rank_sum_positive - n_pos_f * (n_pos_f + 1.0) / 2.0) / (n_pos_f * n_neg_f)
314    }
315
316    /// Real AUC-PR: sweep the score threshold from highest to lowest score,
317    /// tracking precision/recall at each step, and integrate the
318    /// precision-recall curve via the trapezoidal rule.
319    fn compute_auc_pr(scores: &Array1<f64>, true_labels: &Array1<i32>) -> f64 {
320        let n_pos = true_labels.iter().filter(|&&l| l > 0).count();
321        if n_pos == 0 {
322            return f64::NAN;
323        }
324
325        let mut indexed: Vec<(f64, i32)> = scores
326            .iter()
327            .cloned()
328            .zip(true_labels.iter().cloned())
329            .collect();
330        indexed.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
331
332        let mut true_positive = 0.0_f64;
333        let mut false_positive = 0.0_f64;
334        let n_pos_f = n_pos as f64;
335
336        let mut points: Vec<(f64, f64)> = vec![(0.0, 1.0)]; // (recall, precision)
337        for (_, label) in &indexed {
338            if *label > 0 {
339                true_positive += 1.0;
340            } else {
341                false_positive += 1.0;
342            }
343            let recall = true_positive / n_pos_f;
344            let precision = true_positive / (true_positive + false_positive);
345            points.push((recall, precision));
346        }
347
348        let mut area = 0.0;
349        for window in points.windows(2) {
350            let (recall_a, precision_a) = window[0];
351            let (recall_b, precision_b) = window[1];
352            area += (recall_b - recall_a) * (precision_a + precision_b) / 2.0;
353        }
354        area
355    }
356}
357
358impl AnomalyDetectorTrait for QuantumIsolationForest {
359    fn fit(&mut self, data: &Array2<f64>) -> Result<()> {
360        self.feature_stats = Some(Array2::zeros((data.ncols(), 4))); // Placeholder
361        self.build_trees(data)
362    }
363
364    fn detect(&self, data: &Array2<f64>) -> Result<AnomalyResult> {
365        let anomaly_scores = self.compute_scores(data)?;
366        let n_samples = data.nrows();
367        let n_features = data.ncols();
368
369        // Generate binary labels based on contamination
370        let threshold = self.compute_threshold(&anomaly_scores)?;
371        let anomaly_labels = anomaly_scores.mapv(|score| if score > threshold { 1 } else { 0 });
372
373        // Compute confidence scores (same as anomaly scores for now)
374        let confidence_scores = anomaly_scores.clone();
375
376        // Feature importance (placeholder)
377        let feature_importance =
378            Array2::from_elem((n_samples, n_features), 1.0 / n_features as f64);
379
380        // Method-specific results
381        let mut method_results = HashMap::new();
382        method_results.insert(
383            "isolation_forest".to_string(),
384            MethodSpecificResult::IsolationForest {
385                path_lengths: anomaly_scores.clone(),
386                tree_depths: Array1::from_elem(n_samples, 10.0), // Placeholder
387            },
388        );
389
390        // `detect()` is unsupervised (no ground-truth labels are passed in),
391        // so the confusion-matrix-based metrics below (AUC-ROC/PR,
392        // precision/recall/F1, MCC, balanced accuracy, FPR/FNR) are not
393        // computable here -- they previously held fixed, entirely fabricated
394        // constants regardless of `data`. `f64::NAN` makes that honestly
395        // explicit (any comparison against a NaN is false, so a caller can't
396        // mistake it for a real score); call [`Self::evaluate`] with
397        // ground-truth labels to get real values for these fields.
398        //
399        // The quantum_metrics sub-fields are NaN for the same reason: this
400        // isolation forest's splits are chosen purely classically
401        // (`thread_rng().random_range`/`random::<f64>()` in `build_tree`);
402        // `quantum_split`/`quantum_splitting` are recorded flags that do not
403        // currently influence split selection, so there is no real quantum
404        // circuit execution here to derive "quantum advantage" or
405        // "entanglement utilization" from.
406        let metrics = Self::not_computed_metrics();
407
408        Ok(AnomalyResult {
409            anomaly_scores,
410            anomaly_labels,
411            confidence_scores,
412            feature_importance,
413            method_results,
414            metrics,
415            processing_stats: ProcessingStats {
416                total_time: 0.1,
417                quantum_time: 0.03,
418                classical_time: 0.07,
419                memory_usage: 50.0,
420                quantum_executions: n_samples,
421                avg_circuit_depth: 8.0,
422            },
423        })
424    }
425
426    fn update(&mut self, _data: &Array2<f64>, _labels: Option<&Array1<i32>>) -> Result<()> {
427        // Placeholder for online learning
428        Ok(())
429    }
430
431    fn get_config(&self) -> String {
432        format!("QuantumIsolationForest with {} trees", self.trees.len())
433    }
434
435    fn get_type(&self) -> String {
436        "QuantumIsolationForest".to_string()
437    }
438}
439
440impl QuantumIsolationTree {
441    /// Create new quantum isolation tree
442    pub fn new(max_depth: Option<usize>, quantum_splitting: bool) -> Self {
443        QuantumIsolationTree {
444            root: None,
445            max_depth: max_depth.unwrap_or(10),
446            quantum_splitting,
447        }
448    }
449
450    /// Fit tree to data
451    pub fn fit(&mut self, data: &Array2<f64>) -> Result<()> {
452        self.root = Some(self.build_tree(data, 0)?);
453        Ok(())
454    }
455
456    /// Build tree recursively
457    fn build_tree(&self, data: &Array2<f64>, depth: usize) -> Result<QuantumIsolationNode> {
458        let n_samples = data.nrows();
459        let n_features = data.ncols();
460
461        // Stop conditions
462        if depth >= self.max_depth || n_samples <= 1 {
463            return Ok(QuantumIsolationNode {
464                split_feature: 0,
465                split_value: 0.0,
466                left: None,
467                right: None,
468                depth,
469                size: n_samples,
470                quantum_split: false,
471            });
472        }
473
474        // Random feature selection
475        let split_feature = thread_rng().random_range(0..n_features);
476        let feature_values = data.column(split_feature);
477
478        // Compute split value
479        let min_val = feature_values.fold(f64::INFINITY, |a, &b| a.min(b));
480        let max_val = feature_values.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
481        let split_value = min_val + thread_rng().random::<f64>() * (max_val - min_val);
482
483        // Split data
484        let (left_data, right_data) = self.split_data(data, split_feature, split_value)?;
485
486        // Build child nodes
487        let left = if left_data.nrows() > 0 {
488            Some(Box::new(self.build_tree(&left_data, depth + 1)?))
489        } else {
490            None
491        };
492
493        let right = if right_data.nrows() > 0 {
494            Some(Box::new(self.build_tree(&right_data, depth + 1)?))
495        } else {
496            None
497        };
498
499        Ok(QuantumIsolationNode {
500            split_feature,
501            split_value,
502            left,
503            right,
504            depth,
505            size: n_samples,
506            quantum_split: self.quantum_splitting,
507        })
508    }
509
510    /// Split data based on feature and value
511    fn split_data(
512        &self,
513        data: &Array2<f64>,
514        feature: usize,
515        value: f64,
516    ) -> Result<(Array2<f64>, Array2<f64>)> {
517        let mut left_indices = Vec::new();
518        let mut right_indices = Vec::new();
519
520        for i in 0..data.nrows() {
521            if data[[i, feature]] <= value {
522                left_indices.push(i);
523            } else {
524                right_indices.push(i);
525            }
526        }
527
528        let left_data = if !left_indices.is_empty() {
529            data.select(Axis(0), &left_indices)
530        } else {
531            Array2::zeros((0, data.ncols()))
532        };
533
534        let right_data = if !right_indices.is_empty() {
535            data.select(Axis(0), &right_indices)
536        } else {
537            Array2::zeros((0, data.ncols()))
538        };
539
540        Ok((left_data, right_data))
541    }
542
543    /// Compute path length for a sample
544    pub fn path_length(&self, sample: &Array1<f64>) -> Result<f64> {
545        if let Some(ref root) = self.root {
546            Ok(self.traverse_tree(root, sample, 0.0))
547        } else {
548            Ok(0.0)
549        }
550    }
551
552    /// Traverse tree to compute path length
553    fn traverse_tree(&self, node: &QuantumIsolationNode, sample: &Array1<f64>, depth: f64) -> f64 {
554        // Leaf node
555        if node.left.is_none() && node.right.is_none() {
556            return depth + self.compute_c_value(node.size);
557        }
558
559        // Internal node
560        if sample[node.split_feature] <= node.split_value {
561            if let Some(ref left) = node.left {
562                return self.traverse_tree(left, sample, depth + 1.0);
563            }
564        } else {
565            if let Some(ref right) = node.right {
566                return self.traverse_tree(right, sample, depth + 1.0);
567            }
568        }
569
570        depth
571    }
572
573    /// Compute c(n) value for path length normalization
574    fn compute_c_value(&self, n: usize) -> f64 {
575        if n <= 1 {
576            return 1.0;
577        }
578        2.0 * (n as f64 - 1.0).ln() - 2.0 * (n - 1) as f64 / n as f64
579    }
580}
581
582#[cfg(test)]
583mod regression_tests {
584    use super::*;
585    use crate::anomaly_detection::config::QuantumAnomalyConfig;
586
587    fn make_forest() -> QuantumIsolationForest {
588        QuantumIsolationForest::new(QuantumAnomalyConfig::default()).expect("construction")
589    }
590
591    /// Two tight clusters plus a few far-away outliers, with ground-truth
592    /// labels marking the outliers as anomalies.
593    fn clustered_data_with_labels() -> (Array2<f64>, Array1<i32>) {
594        let mut rows = Vec::new();
595        for i in 0..20 {
596            let jitter = (i as f64) * 0.001;
597            rows.push(vec![0.0 + jitter, 0.0 + jitter]);
598        }
599        // Clear outliers, far from the cluster.
600        rows.push(vec![50.0, 50.0]);
601        rows.push(vec![-50.0, -50.0]);
602
603        let n = rows.len();
604        let data = Array2::from_shape_vec((n, 2), rows.concat()).expect("valid shape");
605        let mut labels = vec![0i32; n];
606        labels[n - 1] = 1;
607        labels[n - 2] = 1;
608        (data, Array1::from_vec(labels))
609    }
610
611    /// Regression test for the "detect() returns hardcoded metrics" bug:
612    /// `detect()` has no ground truth, so its metrics must be honestly
613    /// marked as not computed (NaN), not a fixed set of plausible-looking
614    /// constants that never reflect `data`.
615    #[test]
616    fn detect_reports_not_computed_metrics() {
617        let mut forest = make_forest();
618        let (data, _labels) = clustered_data_with_labels();
619        forest.fit(&data).expect("fit should succeed");
620
621        let result = forest.detect(&data).expect("detect should succeed");
622        assert!(result.metrics.auc_roc.is_nan());
623        assert!(result.metrics.precision.is_nan());
624        assert!(result.metrics.recall.is_nan());
625        assert!(result.metrics.f1_score.is_nan());
626        assert!(result.metrics.mcc.is_nan());
627        assert!(result.metrics.quantum_metrics.quantum_advantage.is_nan());
628    }
629
630    /// Regression test: `evaluate()` must compute real confusion-matrix
631    /// metrics from the actual scores/labels, not fabricate them. With two
632    /// obvious outliers correctly isolated, precision/recall should both be
633    /// meaningfully high (not the old hardcoded 0.75/0.70) and MCC positive.
634    #[test]
635    fn evaluate_computes_real_metrics_from_ground_truth() {
636        let mut forest = make_forest();
637        let (data, labels) = clustered_data_with_labels();
638        forest.fit(&data).expect("fit should succeed");
639
640        let metrics = forest
641            .evaluate(&data, &labels)
642            .expect("evaluate should succeed");
643
644        assert!(!metrics.precision.is_nan());
645        assert!(!metrics.recall.is_nan());
646        assert!(metrics.recall > 0.0, "recall was {}", metrics.recall);
647        assert!(
648            metrics.auc_roc > 0.5,
649            "expected better-than-random AUC-ROC for obviously separated \
650             outliers, got {}",
651            metrics.auc_roc
652        );
653        assert!(
654            metrics.mcc > 0.0,
655            "expected positive MCC for a model that isolates real outliers, got {}",
656            metrics.mcc
657        );
658        // Quantum metrics remain honestly unmeasured: no real circuit
659        // execution backs them in this classical implementation.
660        assert!(metrics.quantum_metrics.quantum_advantage.is_nan());
661    }
662
663    #[test]
664    fn evaluate_rejects_mismatched_label_length() {
665        let mut forest = make_forest();
666        let (data, _labels) = clustered_data_with_labels();
667        forest.fit(&data).expect("fit should succeed");
668
669        let wrong_labels = Array1::from_vec(vec![0i32, 1]);
670        assert!(forest.evaluate(&data, &wrong_labels).is_err());
671    }
672
673    #[test]
674    fn auc_roc_is_perfect_for_perfectly_separated_scores() {
675        // Scores strictly increasing with the positive class perfectly
676        // ranked above the negative class: AUC-ROC must be exactly 1.0.
677        let scores = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.9, 1.0]);
678        let labels = Array1::from_vec(vec![0, 0, 0, 1, 1]);
679        let auc = QuantumIsolationForest::compute_auc_roc(&scores, &labels);
680        assert!(
681            (auc - 1.0).abs() < 1e-9,
682            "expected perfect AUC-ROC, got {auc}"
683        );
684    }
685
686    #[test]
687    fn auc_roc_is_chance_for_symmetric_scores() {
688        // Positive-class scores {2, 3} and negative-class scores {1, 4}: of
689        // the 4 (positive, negative) pairs, exactly 2 have the positive
690        // score ranked above the negative one, giving AUC = 2/4 = 0.5.
691        let scores = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
692        let labels = Array1::from_vec(vec![0, 1, 1, 0]);
693        let auc = QuantumIsolationForest::compute_auc_roc(&scores, &labels);
694        assert!(
695            (auc - 0.5).abs() < 1e-9,
696            "expected chance-level AUC-ROC, got {auc}"
697        );
698    }
699}