Skip to main content

shap_rs/
model.rs

1//! Model prediction abstractions for `shap-rs`.
2//!
3//! The model layer intentionally knows nothing about SHAP itself.
4//! It provides the interface through which explainers evaluate models.
5
6use ndarray::{Array2, Array3, ArrayView2};
7use std::collections::{HashMap, VecDeque};
8use std::sync::Mutex;
9
10use crate::error::{Result, ShapError};
11
12/// A model that can produce predictions for a batch of samples.
13///
14/// SHAP explainers frequently evaluate hundreds or thousands of masked
15/// samples. Therefore the fundamental interface is batch-oriented rather
16/// than sample-oriented.
17///
18/// Implementations should prefer efficient vectorized/batched prediction
19/// whenever the underlying model supports it.
20pub trait Predict {
21    /// Predict outputs for a batch of samples.
22    ///
23    /// # Arguments
24    ///
25    /// * `x` - A two-dimensional array with shape
26    ///   `(n_samples, n_features)`.
27    ///
28    /// # Returns
29    ///
30    /// A two-dimensional array with shape
31    /// `(n_samples, n_outputs)`.
32    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>>;
33
34    /// Predicts from an owned batch.
35    ///
36    /// The default delegates to [`Predict::predict`]. Device adapters may
37    /// override this to move a coalition batch directly into their transfer
38    /// or tensor-construction path without an intermediate host copy.
39    fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
40        self.predict(x.view())
41    }
42
43    /// Returns the number of input features expected by the model.
44    ///
45    /// Implementations should return `None` when the model does not expose
46    /// this information.
47    fn n_features(&self) -> Option<usize> {
48        None
49    }
50
51    /// Returns the number of outputs produced by the model.
52    ///
53    /// Implementations should return `None` when the model does not expose
54    /// this information.
55    fn n_outputs(&self) -> Option<usize> {
56        None
57    }
58}
59impl<T: Predict + ?Sized> Predict for &T {
60    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
61        (**self).predict(x)
62    }
63    fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
64        (**self).predict_owned(x)
65    }
66    fn n_features(&self) -> Option<usize> {
67        (**self).n_features()
68    }
69    fn n_outputs(&self) -> Option<usize> {
70        (**self).n_outputs()
71    }
72}
73
74#[derive(Debug)]
75struct PredictionCacheState {
76    rows: HashMap<Vec<u64>, Vec<f64>>,
77    order: VecDeque<Vec<u64>>,
78    outputs: Option<usize>,
79}
80
81/// Adds bounded, reusable row-level prediction caching to a deterministic model.
82///
83/// Cache keys use every input value's exact IEEE-754 bits, so distinct NaN
84/// payloads and signed zero are not conflated. The wrapper is opt-in because it
85/// is only semantically safe for models whose predictions do not change for an
86/// identical row. Its cache is shared across samples, explainer calls, and
87/// threads; concurrent misses may be evaluated more than once but never corrupt
88/// cached values.
89pub struct CachedModel<M> {
90    inner: M,
91    capacity: usize,
92    state: Mutex<PredictionCacheState>,
93}
94
95impl<M> CachedModel<M> {
96    pub fn new(inner: M, capacity: usize) -> Result<Self> {
97        if capacity == 0 {
98            return Err(ShapError::InvalidConfiguration(
99                "prediction cache capacity must be positive".into(),
100            ));
101        }
102        Ok(Self {
103            inner,
104            capacity,
105            state: Mutex::new(PredictionCacheState {
106                rows: HashMap::new(),
107                order: VecDeque::new(),
108                outputs: None,
109            }),
110        })
111    }
112
113    pub fn inner(&self) -> &M {
114        &self.inner
115    }
116
117    pub fn capacity(&self) -> usize {
118        self.capacity
119    }
120
121    pub fn len(&self) -> Result<usize> {
122        Ok(self.lock_state()?.rows.len())
123    }
124
125    pub fn is_empty(&self) -> Result<bool> {
126        Ok(self.len()? == 0)
127    }
128
129    pub fn clear(&self) -> Result<()> {
130        let mut state = self.lock_state()?;
131        state.rows.clear();
132        state.order.clear();
133        state.outputs = None;
134        Ok(())
135    }
136
137    fn lock_state(&self) -> Result<std::sync::MutexGuard<'_, PredictionCacheState>> {
138        self.state
139            .lock()
140            .map_err(|_| ShapError::Other("prediction cache lock was poisoned".into()))
141    }
142
143    fn touch(state: &mut PredictionCacheState, key: &[u64]) {
144        if let Some(position) = state.order.iter().position(|cached| cached == key) {
145            state.order.remove(position);
146        }
147        state.order.push_back(key.to_vec());
148    }
149}
150
151impl<M: Predict> Predict for CachedModel<M> {
152    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
153        if let Some(features) = self.inner.n_features() {
154            if features != x.ncols() {
155                return Err(ShapError::DimensionMismatch {
156                    expected: format!("{features} model features"),
157                    found: format!("{}", x.ncols()),
158                });
159            }
160        }
161        if x.nrows() == 0 {
162            let predictions = self.inner.predict(x)?;
163            if predictions.nrows() != 0 || predictions.ncols() == 0 {
164                return Err(ShapError::DimensionMismatch {
165                    expected: "(0, outputs>0)".into(),
166                    found: format!("{:?}", predictions.dim()),
167                });
168            }
169            let mut state = self.lock_state()?;
170            if state
171                .outputs
172                .is_some_and(|outputs| outputs != predictions.ncols())
173            {
174                return Err(ShapError::OutputDimensionMismatch {
175                    expected: state.outputs.unwrap(),
176                    found: predictions.ncols(),
177                });
178            }
179            state.outputs = Some(predictions.ncols());
180            return Ok(predictions);
181        }
182        let keys = x
183            .rows()
184            .into_iter()
185            .map(|row| row.iter().map(|value| value.to_bits()).collect::<Vec<_>>())
186            .collect::<Vec<_>>();
187        let mut values = vec![None; x.nrows()];
188        let mut missing = Vec::<Vec<u64>>::new();
189        let mut missing_lookup = HashMap::<Vec<u64>, usize>::new();
190        {
191            let mut state = self.lock_state()?;
192            for (row, key) in keys.iter().enumerate() {
193                if let Some(cached) = state.rows.get(key).cloned() {
194                    Self::touch(&mut state, key);
195                    values[row] = Some(cached);
196                } else if !missing_lookup.contains_key(key) {
197                    missing_lookup.insert(key.clone(), missing.len());
198                    missing.push(key.clone());
199                }
200            }
201        }
202
203        let mut predicted_missing = Vec::new();
204        if !missing.is_empty() {
205            crate::error::checked_f64_shape(
206                &[missing.len(), x.ncols()],
207                "prediction cache miss batch",
208            )?;
209            let mut batch = Array2::zeros((missing.len(), x.ncols()));
210            for (row, key) in missing.iter().enumerate() {
211                for (column, bits) in key.iter().enumerate() {
212                    batch[[row, column]] = f64::from_bits(*bits);
213                }
214            }
215            let predictions = self.inner.predict_owned(batch)?;
216            if predictions.nrows() != missing.len() || predictions.ncols() == 0 {
217                return Err(ShapError::DimensionMismatch {
218                    expected: format!("({}, outputs>0)", missing.len()),
219                    found: format!("{:?}", predictions.dim()),
220                });
221            }
222            if predictions.iter().any(|value| !value.is_finite()) {
223                return Err(ShapError::ModelError(
224                    "prediction contains a non-finite value".into(),
225                ));
226            }
227            predicted_missing = predictions
228                .rows()
229                .into_iter()
230                .map(|row| row.to_vec())
231                .collect();
232            let mut state = self.lock_state()?;
233            if state
234                .outputs
235                .is_some_and(|outputs| outputs != predictions.ncols())
236            {
237                return Err(ShapError::OutputDimensionMismatch {
238                    expected: state.outputs.unwrap(),
239                    found: predictions.ncols(),
240                });
241            }
242            state.outputs = Some(predictions.ncols());
243            for (key, prediction) in missing.iter().zip(&predicted_missing) {
244                while state.rows.len() >= self.capacity {
245                    let Some(evicted) = state.order.pop_front() else {
246                        break;
247                    };
248                    state.rows.remove(&evicted);
249                }
250                state.rows.insert(key.clone(), prediction.clone());
251                Self::touch(&mut state, key);
252            }
253        }
254
255        for (row, key) in keys.iter().enumerate() {
256            if values[row].is_none() {
257                values[row] = Some(predicted_missing[missing_lookup[key]].clone());
258            }
259        }
260        let outputs = values
261            .first()
262            .and_then(Option::as_ref)
263            .map(Vec::len)
264            .or_else(|| self.lock_state().ok().and_then(|state| state.outputs))
265            .or_else(|| self.inner.n_outputs())
266            .unwrap_or(0);
267        let flat = values.into_iter().flatten().flatten().collect::<Vec<_>>();
268        Array2::from_shape_vec((x.nrows(), outputs), flat)
269            .map_err(|error| ShapError::ModelError(error.to_string()))
270    }
271
272    fn n_features(&self) -> Option<usize> {
273        self.inner.n_features()
274    }
275
276    fn n_outputs(&self) -> Option<usize> {
277        self.inner
278            .n_outputs()
279            .or_else(|| self.lock_state().ok().and_then(|state| state.outputs))
280    }
281}
282/// A prediction model exposing input gradients with shape
283/// `(samples, features, outputs)`.
284pub trait DifferentiablePredict: Predict {
285    fn gradients(&self, x: ArrayView2<'_, f64>) -> Result<Array3<f64>>;
286}
287impl<T: DifferentiablePredict + ?Sized> DifferentiablePredict for &T {
288    fn gradients(&self, x: ArrayView2<'_, f64>) -> Result<Array3<f64>> {
289        (**self).gradients(x)
290    }
291}
292/// Neural-network adapter capable of propagating Deep SHAP multipliers.
293pub trait DeepAttribution: Predict {
294    fn deep_contributions(
295        &self,
296        x: ArrayView2<'_, f64>,
297        background: ArrayView2<'_, f64>,
298    ) -> Result<Array3<f64>>;
299}
300impl<T: DeepAttribution + ?Sized> DeepAttribution for &T {
301    fn deep_contributions(
302        &self,
303        x: ArrayView2<'_, f64>,
304        background: ArrayView2<'_, f64>,
305    ) -> Result<Array3<f64>> {
306        (**self).deep_contributions(x, background)
307    }
308}
309/// Execution target exposed by accelerated model adapters.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
311pub enum ExecutionDevice {
312    Cpu,
313    Cuda(u16),
314    Metal,
315    Vulkan,
316    WebGpu,
317}
318pub trait AcceleratedPredict: Predict {
319    fn predict_on(&self, x: ArrayView2<'_, f64>, device: ExecutionDevice) -> Result<Array2<f64>>;
320
321    /// Owned-batch device prediction fast path.
322    fn predict_owned_on(&self, x: Array2<f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
323        self.predict_on(x.view(), device)
324    }
325}
326/// Adapts a device-aware prediction closure into an accelerated model.
327///
328/// Ordinary [`Predict`] calls use [`ExecutionDevice::Cpu`]; bind another
329/// device with [`DeviceModel`] when passing the model to an explainer.
330pub struct FnAcceleratedModel<F> {
331    predict_fn: F,
332    n_features: Option<usize>,
333    n_outputs: Option<usize>,
334}
335impl<F> FnAcceleratedModel<F> {
336    pub fn new(predict_fn: F) -> Self {
337        Self {
338            predict_fn,
339            n_features: None,
340            n_outputs: None,
341        }
342    }
343    pub fn with_n_features(mut self, n_features: usize) -> Self {
344        self.n_features = Some(n_features);
345        self
346    }
347    pub fn with_n_outputs(mut self, n_outputs: usize) -> Self {
348        self.n_outputs = Some(n_outputs);
349        self
350    }
351}
352impl<F> Predict for FnAcceleratedModel<F>
353where
354    F: Fn(ArrayView2<'_, f64>, ExecutionDevice) -> Result<Array2<f64>>,
355{
356    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
357        (self.predict_fn)(x, ExecutionDevice::Cpu)
358    }
359    fn n_features(&self) -> Option<usize> {
360        self.n_features
361    }
362    fn n_outputs(&self) -> Option<usize> {
363        self.n_outputs
364    }
365}
366impl<F> AcceleratedPredict for FnAcceleratedModel<F>
367where
368    F: Fn(ArrayView2<'_, f64>, ExecutionDevice) -> Result<Array2<f64>>,
369{
370    fn predict_on(&self, x: ArrayView2<'_, f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
371        (self.predict_fn)(x, device)
372    }
373}
374
375/// Adapts an owned-batch device prediction closure.
376///
377/// This is the preferred closure adapter for GPU coalition evaluation because
378/// the evaluator's combined batch moves into the closure exactly once.
379pub struct FnOwnedAcceleratedModel<F> {
380    predict_fn: F,
381    n_features: Option<usize>,
382    n_outputs: Option<usize>,
383}
384
385impl<F> FnOwnedAcceleratedModel<F> {
386    pub fn new(predict_fn: F) -> Self {
387        Self {
388            predict_fn,
389            n_features: None,
390            n_outputs: None,
391        }
392    }
393
394    pub fn with_n_features(mut self, n_features: usize) -> Self {
395        self.n_features = Some(n_features);
396        self
397    }
398
399    pub fn with_n_outputs(mut self, n_outputs: usize) -> Self {
400        self.n_outputs = Some(n_outputs);
401        self
402    }
403}
404
405impl<F> Predict for FnOwnedAcceleratedModel<F>
406where
407    F: Fn(Array2<f64>, ExecutionDevice) -> Result<Array2<f64>>,
408{
409    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
410        (self.predict_fn)(x.to_owned(), ExecutionDevice::Cpu)
411    }
412
413    fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
414        (self.predict_fn)(x, ExecutionDevice::Cpu)
415    }
416
417    fn n_features(&self) -> Option<usize> {
418        self.n_features
419    }
420
421    fn n_outputs(&self) -> Option<usize> {
422        self.n_outputs
423    }
424}
425
426impl<F> AcceleratedPredict for FnOwnedAcceleratedModel<F>
427where
428    F: Fn(Array2<f64>, ExecutionDevice) -> Result<Array2<f64>>,
429{
430    fn predict_on(&self, x: ArrayView2<'_, f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
431        (self.predict_fn)(x.to_owned(), device)
432    }
433
434    fn predict_owned_on(&self, x: Array2<f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
435        (self.predict_fn)(x, device)
436    }
437}
438/// Binds an accelerated model to a device while retaining the ordinary
439/// [`Predict`] interface consumed by every explainer.
440pub struct DeviceModel<'a, M> {
441    model: &'a M,
442    device: ExecutionDevice,
443}
444impl<'a, M> DeviceModel<'a, M> {
445    pub fn new(model: &'a M, device: ExecutionDevice) -> Self {
446        Self { model, device }
447    }
448    pub fn device(&self) -> ExecutionDevice {
449        self.device
450    }
451}
452impl<M: AcceleratedPredict> Predict for DeviceModel<'_, M> {
453    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
454        self.model.predict_on(x, self.device)
455    }
456    fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
457        self.model.predict_owned_on(x, self.device)
458    }
459    fn n_features(&self) -> Option<usize> {
460        self.model.n_features()
461    }
462    fn n_outputs(&self) -> Option<usize> {
463        self.model.n_outputs()
464    }
465}
466
467/// A convenience implementation allowing a prediction closure to be used
468/// as a SHAP model.
469///
470/// This is particularly useful for model-agnostic explainers.
471///
472/// # Example
473///
474/// ```
475/// use ndarray::{Array2, ArrayView2};
476/// use shap_rs::model::{FnModel, Predict};
477///
478/// let model = FnModel::new(|x: ArrayView2<'_, f64>| -> shap_rs::Result<Array2<f64>> {
479///     let mut output = Array2::<f64>::zeros((x.nrows(), 1));
480///
481///     for i in 0..x.nrows() {
482///         output[[i, 0]] = x[[i, 0]] + x[[i, 1]];
483///     }
484///
485///     Ok(output)
486/// });
487/// ```
488pub struct FnModel<F> {
489    predict_fn: F,
490    n_features: Option<usize>,
491    n_outputs: Option<usize>,
492}
493
494impl<F> FnModel<F> {
495    /// Creates a model from a batch prediction function.
496    pub fn new(predict_fn: F) -> Self {
497        Self {
498            predict_fn,
499            n_features: None,
500            n_outputs: None,
501        }
502    }
503
504    /// Sets the expected number of input features.
505    pub fn with_n_features(mut self, n_features: usize) -> Self {
506        self.n_features = Some(n_features);
507        self
508    }
509
510    /// Sets the number of model outputs.
511    pub fn with_n_outputs(mut self, n_outputs: usize) -> Self {
512        self.n_outputs = Some(n_outputs);
513        self
514    }
515
516    /// Returns the configured feature count.
517    pub fn configured_n_features(&self) -> Option<usize> {
518        self.n_features
519    }
520
521    /// Returns the configured output count.
522    pub fn configured_n_outputs(&self) -> Option<usize> {
523        self.n_outputs
524    }
525}
526
527impl<F> Predict for FnModel<F>
528where
529    F: Fn(ArrayView2<'_, f64>) -> Result<Array2<f64>>,
530{
531    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
532        (self.predict_fn)(x)
533    }
534
535    fn n_features(&self) -> Option<usize> {
536        self.n_features
537    }
538
539    fn n_outputs(&self) -> Option<usize> {
540        self.n_outputs
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use ndarray::array;
548    use std::cell::Cell;
549
550    #[test]
551    fn fn_model_predicts_batch() {
552        let model = FnModel::new(|x: ArrayView2<'_, f64>| {
553            let mut output = Array2::<f64>::zeros((x.nrows(), 1));
554
555            for i in 0..x.nrows() {
556                output[[i, 0]] = x[[i, 0]] + x[[i, 1]];
557            }
558
559            Ok(output)
560        });
561
562        let x = array![[1.0, 2.0], [3.0, 4.0],];
563
564        let predictions = model.predict(x.view()).unwrap();
565
566        assert_eq!(predictions.shape(), &[2, 1]);
567        assert_eq!(predictions[[0, 0]], 3.0);
568        assert_eq!(predictions[[1, 0]], 7.0);
569    }
570
571    #[test]
572    fn accelerated_closure_receives_bound_device() {
573        let model = FnAcceleratedModel::new(|x: ArrayView2<'_, f64>, device| {
574            let offset = if device == ExecutionDevice::Cuda(2) {
575                10.0
576            } else {
577                0.0
578            };
579            Ok(Array2::from_shape_fn((x.nrows(), 1), |(i, _)| {
580                x[[i, 0]] + offset
581            }))
582        });
583        let bound = DeviceModel::new(&model, ExecutionDevice::Cuda(2));
584        assert_eq!(bound.predict(array![[3.0]].view()).unwrap()[[0, 0]], 13.0);
585        assert_eq!(model.predict(array![[3.0]].view()).unwrap()[[0, 0]], 3.0);
586    }
587
588    #[test]
589    fn device_model_dispatches_owned_batches_to_transfer_fast_path() {
590        struct TrackingModel {
591            borrowed_calls: Cell<usize>,
592            owned_calls: Cell<usize>,
593        }
594        impl Predict for TrackingModel {
595            fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
596                Ok(x.to_owned())
597            }
598        }
599        impl AcceleratedPredict for TrackingModel {
600            fn predict_on(
601                &self,
602                x: ArrayView2<'_, f64>,
603                _: ExecutionDevice,
604            ) -> Result<Array2<f64>> {
605                self.borrowed_calls.set(self.borrowed_calls.get() + 1);
606                Ok(x.to_owned())
607            }
608            fn predict_owned_on(&self, x: Array2<f64>, _: ExecutionDevice) -> Result<Array2<f64>> {
609                self.owned_calls.set(self.owned_calls.get() + 1);
610                Ok(x)
611            }
612        }
613        let model = TrackingModel {
614            borrowed_calls: Cell::new(0),
615            owned_calls: Cell::new(0),
616        };
617        let bound = DeviceModel::new(&model, ExecutionDevice::Cuda(0));
618        let output = bound.predict_owned(array![[1., 2.]]).unwrap();
619        assert_eq!(output, array![[1., 2.]]);
620        assert_eq!(model.owned_calls.get(), 1);
621        assert_eq!(model.borrowed_calls.get(), 0);
622    }
623
624    #[test]
625    fn owned_accelerated_closure_receives_batch_and_device() {
626        let model = FnOwnedAcceleratedModel::new(|x: Array2<f64>, device| {
627            let offset = if device == ExecutionDevice::Vulkan {
628                2.0
629            } else {
630                0.0
631            };
632            Ok(x + offset)
633        });
634        let bound = DeviceModel::new(&model, ExecutionDevice::Vulkan);
635        assert_eq!(
636            bound.predict_owned(array![[1., 3.]]).unwrap(),
637            array![[3., 5.]]
638        );
639    }
640
641    #[test]
642    fn fn_model_can_store_dimensions() {
643        let model = FnModel::new(|x: ArrayView2<'_, f64>| Ok(Array2::zeros((x.nrows(), 2))))
644            .with_n_features(4)
645            .with_n_outputs(2);
646
647        assert_eq!(model.n_features(), Some(4));
648        assert_eq!(model.n_outputs(), Some(2));
649    }
650
651    #[test]
652    fn fn_model_can_be_used_through_predict_trait() {
653        let model = FnModel::new(|x: ArrayView2<'_, f64>| Ok(Array2::ones((x.nrows(), 1))));
654
655        let x = Array2::<f64>::zeros((3, 2));
656
657        let predictions = Predict::predict(&model, x.view()).unwrap();
658
659        assert_eq!(predictions.shape(), &[3, 1]);
660        assert!(predictions.iter().all(|&value| value == 1.0));
661    }
662
663    #[test]
664    fn cached_model_reuses_rows_across_batches_and_deduplicates_misses() {
665        let calls = Cell::new(0usize);
666        let rows = Cell::new(0usize);
667        let model = FnModel::new(|x: ArrayView2<'_, f64>| {
668            calls.set(calls.get() + 1);
669            rows.set(rows.get() + x.nrows());
670            Ok(x.sum_axis(ndarray::Axis(1)).insert_axis(ndarray::Axis(1)))
671        })
672        .with_n_features(2)
673        .with_n_outputs(1);
674        let cached = CachedModel::new(model, 4).unwrap();
675
676        let first = cached
677            .predict(array![[1., 2.], [3., 4.], [1., 2.]].view())
678            .unwrap();
679        assert_eq!(first, array![[3.], [7.], [3.]]);
680        assert_eq!(calls.get(), 1);
681        assert_eq!(rows.get(), 2);
682
683        let second = cached.predict(array![[3., 4.], [5., 6.]].view()).unwrap();
684        assert_eq!(second, array![[7.], [11.]]);
685        assert_eq!(calls.get(), 2);
686        assert_eq!(rows.get(), 3);
687        assert_eq!(cached.len().unwrap(), 3);
688    }
689
690    #[test]
691    fn cached_model_has_bounded_lru_eviction_and_can_be_cleared() {
692        let rows = Cell::new(0usize);
693        let model = FnModel::new(|x: ArrayView2<'_, f64>| {
694            rows.set(rows.get() + x.nrows());
695            Ok(x.to_owned())
696        });
697        let cached = CachedModel::new(model, 2).unwrap();
698        cached.predict(array![[1.], [2.], [3.]].view()).unwrap();
699        assert_eq!(cached.len().unwrap(), 2);
700        cached.predict(array![[1.]].view()).unwrap();
701        assert_eq!(rows.get(), 4);
702        cached.clear().unwrap();
703        assert!(cached.is_empty().unwrap());
704    }
705
706    #[test]
707    fn cached_model_rejects_zero_capacity() {
708        let model =
709            FnModel::new(|x: ArrayView2<'_, f64>| -> Result<Array2<f64>> { Ok(x.to_owned()) });
710        assert!(CachedModel::new(model, 0).is_err());
711    }
712}