Skip to main content

gam_solve/
latent_cache.rs

1//! Persistent cache for latent-coordinate REML design evaluations.
2//!
3//! This follows the same invalidation shape as the design-revision pattern in
4//! `src/terms/smooth.rs`'s `SpatialLogKappa` path (around the
5//! `SpatialLogKappa` cache near line 12805) and the `EvalShared` rho-keyed
6//! cache in `src/solver/reml/mod.rs` (around line 3525).  REML's outer
7//! evaluator is reentrant for each theta: the rho component is already covered
8//! by `EvalShared`, while the design-moving component is fully determined by
9//! the latent fingerprint.  Together, `(rho, latent_fingerprint)` is sufficient
10//! to reuse the realized surface until the caller bumps the design revision or
11//! explicitly invalidates this cache.
12
13use std::collections::hash_map::DefaultHasher;
14use std::collections::{HashMap, VecDeque};
15use std::hash::{Hash, Hasher};
16use std::path::PathBuf;
17use std::sync::{Arc, Mutex, OnceLock};
18
19use ndarray::{Array1, Array2, ArrayView2};
20
21use crate::estimate::EstimationError;
22use crate::estimate::reml::DirectionalHyperParam;
23pub use gam_problem::LatentRetractionRegistry;
24use gam_runtime::warm_start::{Fingerprint, Fingerprinter};
25use gam_terms::basis::{DuchonNullspaceOrder, MaternNu, RadialScalarKind};
26use gam_terms::latent::{
27    AuxPriorFamily, AuxPriorStrength, LatentCoordValues, LatentIdMode, LatentManifold,
28};
29use gam_terms::smooth::{TermCollectionDesign, TermCollectionSpec};
30
31const DEFAULT_LATENT_CACHE_CAPACITY: usize = 4;
32const DEFAULT_PERSISTENT_LATENT_CACHE_CAPACITY: usize = 16;
33const DEFAULT_PERSISTENT_LATENT_CACHE_BYTE_BUDGET: usize = 1024 * 1024 * 1024;
34
35static PERSISTENT_LATENT_DESIGN_CACHE: OnceLock<Mutex<PersistentLatentDesignCache>> =
36    OnceLock::new();
37
38/// O(N) identity summary for a flat latent-coordinate vector.
39#[derive(Clone, Debug)]
40pub(crate) struct LatentFingerprint {
41    pub(crate) hash: u64,
42    pub(crate) len: usize,
43}
44
45impl LatentFingerprint {
46    pub(crate) fn from_flat(flat: &[f64]) -> Self {
47        let mut hasher = DefaultHasher::new();
48        flat.len().hash(&mut hasher);
49        for &value in flat {
50            value.to_bits().hash(&mut hasher);
51        }
52        Self {
53            hash: hasher.finish(),
54            len: flat.len(),
55        }
56    }
57}
58
59pub type CacheDigest = Fingerprint;
60
61/// Open a [`Fingerprinter`] pre-seeded with a length-prefixed namespace
62/// string so different cache-digest call sites cannot alias.
63///
64/// This is a thin convenience wrapper over [`Fingerprinter::write_str`]
65/// — it exists so the call sites read as `cache_digest_builder("…-v1")`
66/// instead of repeating the namespace-framing pattern at every callsite.
67fn cache_digest_builder(namespace: &str) -> Fingerprinter {
68    let mut out = Fingerprinter::new();
69    out.write_str(namespace);
70    out
71}
72
73#[derive(Clone)]
74pub enum LatentBasisKind {
75    // Basis/evaluator family for Phi(t); the per-row latent values live in LatentCoordValues.
76    Matern {
77        /// Standardized-frame centers, as `BasisMetadata` stores them.
78        centers: Array2<f64>,
79        /// The frame `centers` live in relative to the RAW latent values
80        /// (#2643). `build_radial_distances` standardizes by this before
81        /// forming radii; without it, raw `t` was compared against
82        /// standardized centers at an original-units range.
83        input_scale: gam_terms::IsotropicScale,
84        /// Kernel range in ORIGINAL units, paired with `input_scale`.
85        length_scale: gam_terms::OriginalUnits,
86        nu: MaternNu,
87        aniso_log_scales: Vec<f64>,
88        chunk_size: Option<usize>,
89    },
90    Duchon {
91        centers: Array2<f64>,
92        /// See [`LatentBasisKind::Matern::input_scale`].
93        input_scale: gam_terms::IsotropicScale,
94        length_scale: Option<gam_terms::OriginalUnits>,
95        power: f64,
96        nullspace_order: DuchonNullspaceOrder,
97        aniso_log_scales: Vec<f64>,
98    },
99    Sphere {
100        centers: Array2<f64>,
101        penalty_order: usize,
102        chunk_size: Option<usize>,
103    },
104    PeriodicBspline {
105        domain_start: f64,
106        period: f64,
107        degree: usize,
108        num_basis: usize,
109        chunk_size: Option<usize>,
110    },
111    TensorBspline {
112        knots: Vec<Array1<f64>>,
113        degrees: Vec<usize>,
114        chunk_size: Option<usize>,
115    },
116    Pca {
117        basis_matrix: Array2<f64>,
118        centered: bool,
119        center_mean_fingerprint: Option<u64>,
120        smooth_penalty: f64,
121        pca_basis_path: Option<PathBuf>,
122        chunk_size: usize,
123    },
124}
125
126impl LatentBasisKind {
127    fn centers(&self) -> Option<&Array2<f64>> {
128        match self {
129            Self::Matern { centers, .. }
130            | Self::Duchon { centers, .. }
131            | Self::Sphere { centers, .. } => Some(centers),
132            Self::PeriodicBspline { .. } | Self::TensorBspline { .. } => None,
133            Self::Pca { .. } => None,
134        }
135    }
136
137    /// The frame this kind's `centers` live in, relative to the raw latent
138    /// values.
139    ///
140    /// `Sphere` centers are chart coordinates consumed verbatim — the sphere
141    /// contract is `Intrinsic`, so nothing standardizes them and `ONE` is the
142    /// true frame rather than a stand-in. The bspline and PCA kinds have no
143    /// centers at all and never reach `build_radial_distances`.
144    fn input_scale(&self) -> gam_terms::IsotropicScale {
145        match self {
146            Self::Matern { input_scale, .. } | Self::Duchon { input_scale, .. } => *input_scale,
147            Self::Sphere { .. }
148            | Self::PeriodicBspline { .. }
149            | Self::TensorBspline { .. }
150            | Self::Pca { .. } => gam_terms::IsotropicScale::ONE,
151        }
152    }
153
154    fn streams_radial_cache(&self) -> bool {
155        matches!(
156            self,
157            Self::Matern {
158                chunk_size: Some(_),
159                ..
160            } | Self::Sphere {
161                chunk_size: Some(_),
162                ..
163            }
164        )
165    }
166
167    fn cache_digest(&self) -> CacheDigest {
168        let mut hasher = cache_digest_builder("latent-basis-v1");
169        match self {
170            Self::Matern {
171                centers,
172                input_scale,
173                length_scale,
174                nu,
175                aniso_log_scales,
176                chunk_size,
177            } => {
178                hasher.write_usize(0);
179                hasher.write_usize(centers.nrows());
180                hasher.write_usize(centers.ncols());
181                // Two designs with the same range in different frames are
182                // different designs, so the frame belongs in the key (#2643).
183                hasher.write_f64(input_scale.get());
184                hasher.write_f64(length_scale.original_value());
185                hasher.write_usize(matern_nu_signature(*nu));
186                hasher.write_f64_slice(aniso_log_scales);
187                hash_optional_usize(*chunk_size, &mut hasher);
188                hasher.write_f64_array2(centers);
189            }
190            Self::Duchon {
191                centers,
192                input_scale,
193                length_scale,
194                power,
195                nullspace_order,
196                aniso_log_scales,
197            } => {
198                hasher.write_usize(1);
199                hasher.write_usize(centers.nrows());
200                hasher.write_usize(centers.ncols());
201                hasher.write_f64(input_scale.get());
202                hash_optional_f64(
203                    length_scale.map(gam_terms::OriginalUnits::original_value),
204                    &mut hasher,
205                );
206                hasher.write_u64(power.to_bits());
207                hash_duchon_nullspace_order(*nullspace_order, &mut hasher);
208                hasher.write_f64_slice(aniso_log_scales);
209                hasher.write_f64_array2(centers);
210            }
211            Self::Sphere {
212                centers,
213                penalty_order,
214                chunk_size,
215            } => {
216                hasher.write_usize(2);
217                hasher.write_usize(centers.nrows());
218                hasher.write_usize(centers.ncols());
219                hasher.write_usize(*penalty_order);
220                hash_optional_usize(*chunk_size, &mut hasher);
221                hasher.write_f64_array2(centers);
222            }
223            Self::PeriodicBspline {
224                domain_start,
225                period,
226                degree,
227                num_basis,
228                chunk_size,
229            } => {
230                hasher.write_usize(3);
231                hasher.write_f64(*domain_start);
232                hasher.write_f64(*period);
233                hasher.write_usize(*degree);
234                hasher.write_usize(*num_basis);
235                hash_optional_usize(*chunk_size, &mut hasher);
236            }
237            Self::TensorBspline {
238                knots,
239                degrees,
240                chunk_size,
241            } => {
242                hasher.write_usize(4);
243                hasher.write_usize(degrees.len());
244                for &degree in degrees {
245                    hasher.write_usize(degree);
246                }
247                hash_optional_usize(*chunk_size, &mut hasher);
248                hasher.write_usize(knots.len());
249                for axis_knots in knots {
250                    hasher.write_f64_array1(axis_knots);
251                }
252            }
253            Self::Pca {
254                basis_matrix,
255                centered,
256                center_mean_fingerprint,
257                smooth_penalty,
258                pca_basis_path,
259                chunk_size,
260            } => {
261                hasher.write_usize(5);
262                hasher.write_u8(*centered as u8);
263                if let Some(fp) = center_mean_fingerprint {
264                    hasher.write_u64(*fp);
265                }
266                hasher.write_u64(smooth_penalty.to_bits());
267                if let Some(path) = pca_basis_path {
268                    hasher.write_u8(1);
269                    hasher.write_bytes(path.to_string_lossy().as_bytes());
270                    if let Ok(meta) = std::fs::metadata(path) {
271                        hasher.write_u64(meta.len());
272                        if let Ok(modified) = meta.modified()
273                            && let Ok(elapsed) =
274                                modified.duration_since(std::time::SystemTime::UNIX_EPOCH)
275                        {
276                            hasher.write_u64(elapsed.as_secs());
277                            hasher.write_u64(elapsed.subsec_nanos() as u64);
278                        }
279                    }
280                } else {
281                    hasher.write_u8(0);
282                }
283                hasher.write_usize(*chunk_size);
284                hasher.write_usize(basis_matrix.nrows());
285                hasher.write_usize(basis_matrix.ncols());
286                hasher.write_f64_array2(basis_matrix);
287            }
288        }
289        hasher.finalize()
290    }
291}
292
293pub fn pca_center_mean_fingerprint(mean: &Array1<f64>) -> u64 {
294    let mut hasher = Fingerprinter::new();
295    hasher.write_usize(mean.len());
296    for &value in mean.iter() {
297        hasher.write_f64(value);
298    }
299    hasher.finish_u64()
300}
301
302fn matern_nu_signature(nu: MaternNu) -> usize {
303    match nu {
304        MaternNu::Half => 0,
305        MaternNu::ThreeHalves => 1,
306        MaternNu::FiveHalves => 2,
307        MaternNu::SevenHalves => 3,
308        MaternNu::NineHalves => 4,
309    }
310}
311
312fn hash_duchon_nullspace_order(order: DuchonNullspaceOrder, hasher: &mut Fingerprinter) {
313    match order {
314        DuchonNullspaceOrder::Zero => {
315            hasher.write_usize(0);
316        }
317        DuchonNullspaceOrder::Linear => {
318            hasher.write_usize(1);
319        }
320        DuchonNullspaceOrder::Degree(degree) => {
321            hasher.write_usize(2);
322            hasher.write_usize(degree);
323        }
324    }
325}
326
327fn hash_optional_f64(value: Option<f64>, hasher: &mut Fingerprinter) {
328    match value {
329        Some(value) => {
330            hasher.write_bool(true);
331            hasher.write_f64(value);
332        }
333        None => {
334            hasher.write_bool(false);
335        }
336    }
337}
338
339fn hash_optional_usize(value: Option<usize>, hasher: &mut Fingerprinter) {
340    match value {
341        Some(value) => {
342            hasher.write_bool(true);
343            hasher.write_usize(value);
344        }
345        None => {
346            hasher.write_bool(false);
347        }
348    }
349}
350
351fn latent_metadata_cache_digest(latent: &LatentCoordValues) -> CacheDigest {
352    let mut hasher = cache_digest_builder("latent-cache-metadata-v1");
353    hasher.write_usize(latent.n_obs());
354    hasher.write_usize(latent.latent_dim());
355    hash_latent_manifold(latent.manifold(), &mut hasher);
356    hash_latent_id_mode(latent.id_mode(), &mut hasher);
357    hasher.finalize()
358}
359
360pub fn latent_design_context_cache_digest(
361    data: ArrayView2<'_, f64>,
362    spec: &TermCollectionSpec,
363    term_index: gam_problem::SmoothTermIdx,
364    analytic_rho_count: usize,
365    feature_cols: &[usize],
366) -> Result<CacheDigest, EstimationError> {
367    let mut hasher = cache_digest_builder("latent-design-context-v1");
368    hasher.write_usize(data.nrows());
369    hasher.write_usize(data.ncols());
370    for row in 0..data.nrows() {
371        for col in 0..data.ncols() {
372            hasher.write_f64(data[[row, col]]);
373        }
374    }
375    let spec_bytes = serde_json::to_vec(spec).map_err(|err| {
376        EstimationError::InvalidInput(format!(
377            "failed to serialize latent design cache context: {err}"
378        ))
379    })?;
380    hasher.write_usize(spec_bytes.len());
381    hasher.write_bytes(&spec_bytes);
382    hasher.write_usize(term_index.get());
383    hasher.write_usize(analytic_rho_count);
384    hasher.write_usize(feature_cols.len());
385    for &col in feature_cols {
386        hasher.write_usize(col);
387    }
388    Ok(hasher.finalize())
389}
390
391fn hash_latent_id_mode(id_mode: &LatentIdMode, hasher: &mut Fingerprinter) {
392    match id_mode {
393        LatentIdMode::AuxPrior {
394            u,
395            family,
396            strength,
397        } => {
398            hasher.write_usize(0);
399            hasher.write_f64_array2(u);
400            hash_aux_prior_family(*family, hasher);
401            hash_aux_prior_strength(*strength, hasher);
402        }
403        LatentIdMode::AuxPriorDimSelection {
404            u,
405            family,
406            strength,
407            init_log_precision,
408        } => {
409            hasher.write_usize(1);
410            hasher.write_f64_array2(u);
411            hash_aux_prior_family(*family, hasher);
412            hash_aux_prior_strength(*strength, hasher);
413            hash_optional_vector(init_log_precision.as_ref(), hasher);
414        }
415        LatentIdMode::DimSelection { init_log_precision } => {
416            hasher.write_usize(2);
417            hash_optional_vector(init_log_precision.as_ref(), hasher);
418        }
419        LatentIdMode::IsometryToReference {
420            reference,
421            strength,
422        } => {
423            hasher.write_usize(5);
424            hasher.write_f64_array2(reference);
425            hash_aux_prior_strength(*strength, hasher);
426        }
427        LatentIdMode::AuxOutcome {
428            head,
429            init_log_precision,
430        } => {
431            hasher.write_usize(4);
432            hash_behavioral_head(head, hasher);
433            hash_optional_vector(init_log_precision.as_ref(), hasher);
434        }
435        LatentIdMode::None => {
436            hasher.write_usize(3);
437        }
438    }
439}
440
441fn hash_behavioral_head(
442    head: &gam_terms::decoders::behavioral_head::BehavioralHead,
443    hasher: &mut Fingerprinter,
444) {
445    use gam_terms::decoders::behavioral_head::AuxOutcomeFamily;
446    match head.family() {
447        AuxOutcomeFamily::Binomial => hasher.write_usize(0),
448        AuxOutcomeFamily::Multinomial { n_classes } => {
449            hasher.write_usize(1);
450            hasher.write_usize(n_classes);
451        }
452    }
453    hasher.write_usize(head.n_obs());
454    hasher.write_f64(head.effective_labeled_count());
455}
456
457fn hash_aux_prior_family(family: AuxPriorFamily, hasher: &mut Fingerprinter) {
458    hasher.write_usize(match family {
459        AuxPriorFamily::Ridge => 0,
460        AuxPriorFamily::Linear => 1,
461    });
462}
463
464fn hash_aux_prior_strength(strength: AuxPriorStrength, hasher: &mut Fingerprinter) {
465    match strength {
466        AuxPriorStrength::Auto => {
467            hasher.write_usize(0);
468        }
469        AuxPriorStrength::Fixed(value) => {
470            hasher.write_usize(1);
471            hasher.write_f64(value);
472        }
473    }
474}
475
476fn hash_optional_vector(vector: Option<&Array1<f64>>, hasher: &mut Fingerprinter) {
477    match vector {
478        Some(vector) => {
479            hasher.write_bool(true);
480            hasher.write_f64_array1(vector);
481        }
482        None => {
483            hasher.write_bool(false);
484        }
485    }
486}
487
488fn hash_latent_manifold(manifold: &LatentManifold, hasher: &mut Fingerprinter) {
489    match manifold {
490        LatentManifold::Euclidean => {
491            hasher.write_usize(0);
492        }
493        LatentManifold::Circle { period } => {
494            hasher.write_usize(1);
495            hasher.write_f64(*period);
496        }
497        LatentManifold::Sphere { dim } => {
498            hasher.write_usize(2);
499            hasher.write_usize(*dim);
500        }
501        LatentManifold::Interval { lo, hi } => {
502            hasher.write_usize(3);
503            hasher.write_f64(*lo);
504            hasher.write_f64(*hi);
505        }
506        LatentManifold::Product(parts) => {
507            hasher.write_usize(4);
508            hasher.write_usize(parts.len());
509            for part in parts {
510                hash_latent_manifold(part, hasher);
511            }
512        }
513        LatentManifold::ProductWithMetric { manifolds, weights } => {
514            hasher.write_usize(5);
515            hasher.write_usize(manifolds.len());
516            for part in manifolds {
517                hash_latent_manifold(part, hasher);
518            }
519            hasher.write_f64_slice(weights);
520        }
521    }
522}
523
524#[derive(Clone)]
525pub(crate) struct RadialDistanceMatrices {
526    pub(crate) squared: Array2<f64>,
527    pub(crate) distance: Array2<f64>,
528}
529
530#[derive(Clone)]
531pub(crate) struct BasisDerivativeJets {
532    pub(crate) phi: Option<Array2<f64>>,
533    pub(crate) q: Option<Array2<f64>>,
534    pub(crate) t: Option<Array2<f64>>,
535    pub(crate) phi_r: Option<Array2<f64>>,
536    pub(crate) phi_rr: Option<Array2<f64>>,
537    pub(crate) operator_resident: bool,
538}
539
540impl BasisDerivativeJets {
541    fn empty() -> Self {
542        Self {
543            phi: None,
544            q: None,
545            t: None,
546            phi_r: None,
547            phi_rr: None,
548            operator_resident: false,
549        }
550    }
551}
552
553#[derive(Clone)]
554pub struct CachedDesign {
555    pub(crate) latent_id: u64,
556    pub(crate) fingerprint: LatentFingerprint,
557    basis_digest: CacheDigest,
558    latent_metadata_digest: CacheDigest,
559    design_context_digest: CacheDigest,
560    latent_bits: Arc<[u64]>,
561    cacheable: bool,
562    pub design: TermCollectionDesign,
563    pub hyper_dirs: Vec<DirectionalHyperParam>,
564    pub(crate) radial_distances: RadialDistanceMatrices,
565    pub(crate) basis_derivative_jets: BasisDerivativeJets,
566}
567
568pub struct ComputedLatentDesign {
569    pub design: TermCollectionDesign,
570    pub hyper_dirs: Vec<DirectionalHyperParam>,
571}
572
573pub struct LatentDesignLookup<'a> {
574    pub cached: &'a CachedDesign,
575    pub entry_id: u64,
576}
577
578#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
579struct PersistentLatentDesignKey {
580    latent_id: u64,
581    flat_hash: u64,
582    basis_digest: CacheDigest,
583    latent_metadata_digest: CacheDigest,
584    design_context_digest: CacheDigest,
585}
586
587struct PersistentLatentDesignEntry {
588    fingerprint: LatentFingerprint,
589    cached: Arc<CachedDesign>,
590    bytes: usize,
591}
592
593pub(crate) struct PersistentLatentDesignCache {
594    entries: HashMap<PersistentLatentDesignKey, PersistentLatentDesignEntry>,
595    lru: VecDeque<PersistentLatentDesignKey>,
596    capacity: usize,
597    byte_budget: usize,
598    cache_bytes: usize,
599}
600
601impl Default for PersistentLatentDesignCache {
602    fn default() -> Self {
603        Self::new(DEFAULT_PERSISTENT_LATENT_CACHE_CAPACITY)
604    }
605}
606
607impl PersistentLatentDesignCache {
608    pub(crate) fn new(capacity: usize) -> Self {
609        Self {
610            entries: HashMap::new(),
611            lru: VecDeque::new(),
612            capacity: capacity.max(1),
613            byte_budget: DEFAULT_PERSISTENT_LATENT_CACHE_BYTE_BUDGET,
614            cache_bytes: 0,
615        }
616    }
617
618    pub(crate) fn lookup(
619        &mut self,
620        latent: &LatentCoordValues,
621        basis_digest: CacheDigest,
622        latent_metadata_digest: CacheDigest,
623        design_context_digest: CacheDigest,
624        fingerprint: &LatentFingerprint,
625    ) -> Result<Option<Arc<CachedDesign>>, EstimationError> {
626        let key = PersistentLatentDesignKey {
627            latent_id: latent.latent_id(),
628            flat_hash: fingerprint.hash,
629            basis_digest,
630            latent_metadata_digest,
631            design_context_digest,
632        };
633        let Some(entry) = self.entries.get(&key) else {
634            return Ok(None);
635        };
636        let cached = entry.cached.clone();
637        let entry_fingerprint = entry.fingerprint.clone();
638        self.touch(key);
639        if entry_fingerprint.len != fingerprint.len {
640            return Ok(None);
641        }
642        if entry_fingerprint.hash == fingerprint.hash
643            && cached.cacheable
644            && cached.basis_digest == basis_digest
645            && cached.latent_metadata_digest == latent_metadata_digest
646            && cached.design_context_digest == design_context_digest
647            && latent_bits_match(latent, &cached.latent_bits)
648        {
649            return Ok(Some(cached));
650        }
651        Ok(None)
652    }
653
654    pub(crate) fn insert(&mut self, cached: Arc<CachedDesign>) {
655        if !cached.cacheable {
656            return;
657        }
658        let bytes = cached.resident_byte_count();
659        if bytes > self.byte_budget {
660            return;
661        }
662        let key = PersistentLatentDesignKey {
663            latent_id: cached.latent_id,
664            flat_hash: cached.fingerprint.hash,
665            basis_digest: cached.basis_digest,
666            latent_metadata_digest: cached.latent_metadata_digest,
667            design_context_digest: cached.design_context_digest,
668        };
669        let entry = PersistentLatentDesignEntry {
670            fingerprint: cached.fingerprint.clone(),
671            cached,
672            bytes,
673        };
674        if let Some(old) = self.entries.insert(key, entry) {
675            self.cache_bytes = self.cache_bytes.saturating_sub(old.bytes);
676        }
677        self.cache_bytes = self.cache_bytes.saturating_add(bytes);
678        self.touch(key);
679        self.evict_to_limits();
680    }
681
682    fn evict_to_limits(&mut self) {
683        while self.entries.len() > self.capacity || self.cache_bytes > self.byte_budget {
684            let Some(evicted) = self.lru.pop_front() else {
685                break;
686            };
687            if let Some(entry) = self.entries.remove(&evicted) {
688                self.cache_bytes = self.cache_bytes.saturating_sub(entry.bytes);
689            }
690        }
691    }
692
693    fn touch(&mut self, key: PersistentLatentDesignKey) {
694        if let Some(index) = self.lru.iter().position(|queued| *queued == key) {
695            self.lru.remove(index);
696        }
697        self.lru.push_back(key);
698    }
699}
700
701pub struct LatentDesignCache {
702    entries: Vec<LatentDesignCacheEntry>,
703    capacity: usize,
704    clock: u64,
705    iteration: u64,
706    next_entry_id: u64,
707}
708
709struct LatentDesignCacheEntry {
710    id: u64,
711    cached: Arc<CachedDesign>,
712    last_used: u64,
713    iteration: u64,
714}
715
716impl Default for LatentDesignCache {
717    fn default() -> Self {
718        Self::new(DEFAULT_LATENT_CACHE_CAPACITY)
719    }
720}
721
722impl LatentDesignCache {
723    pub(crate) fn new(capacity: usize) -> Self {
724        Self {
725            entries: Vec::new(),
726            capacity: capacity.max(1),
727            clock: 0,
728            iteration: 0,
729            next_entry_id: 0,
730        }
731    }
732
733    pub fn invalidate(&mut self) {
734        self.entries.clear();
735    }
736
737    pub fn invalidate_all(&mut self) {
738        self.entries.clear();
739        self.clock = self.clock.wrapping_add(1);
740        self.iteration = self.iteration.wrapping_add(1);
741    }
742
743    pub fn lookup_or_compute<F>(
744        &mut self,
745        latent: Arc<LatentCoordValues>,
746        basis_kind: LatentBasisKind,
747        design_context_digest: CacheDigest,
748        compute: F,
749    ) -> Result<LatentDesignLookup<'_>, EstimationError>
750    where
751        F: FnOnce() -> Result<ComputedLatentDesign, EstimationError>,
752    {
753        self.iteration = self.iteration.wrapping_add(1);
754        self.clock = self.clock.wrapping_add(1);
755        let flat = latent.as_flat();
756        let flat_slice = flat
757            .as_slice()
758            .expect("LatentCoordValues flat storage must be contiguous");
759        let fingerprint = LatentFingerprint::from_flat(flat_slice);
760        let basis_digest = basis_kind.cache_digest();
761        let latent_metadata_digest = latent_metadata_cache_digest(&latent);
762        let cacheable = flat_slice.iter().all(|value| value.is_finite());
763        if cacheable
764            && let Some(index) = self.find_entry(
765                &latent,
766                basis_digest,
767                latent_metadata_digest,
768                design_context_digest,
769            )
770        {
771            self.entries[index].last_used = self.clock;
772            return Ok(LatentDesignLookup {
773                cached: self.entries[index].cached.as_ref(),
774                entry_id: self.entries[index].id,
775            });
776        }
777        if cacheable
778            && let Some(cached) = lookup_persistent_latent_design(
779                &latent,
780                basis_digest,
781                latent_metadata_digest,
782                design_context_digest,
783                &fingerprint,
784            )?
785        {
786            let id = self.next_entry_id;
787            self.next_entry_id = self.next_entry_id.wrapping_add(1);
788            self.insert(cached, id);
789            return self.lookup_inserted(id);
790        }
791
792        let computed = compute()?;
793        let radial_distances = if basis_kind.streams_radial_cache() {
794            RadialDistanceMatrices {
795                squared: Array2::<f64>::zeros((0, 0)),
796                distance: Array2::<f64>::zeros((0, 0)),
797            }
798        } else {
799            match basis_kind.centers() {
800                Some(centers) => {
801                    build_radial_distances(&latent, centers, basis_kind.input_scale())?
802                }
803                None => RadialDistanceMatrices {
804                    squared: Array2::<f64>::zeros((0, 0)),
805                    distance: Array2::<f64>::zeros((0, 0)),
806                },
807            }
808        };
809        let basis_derivative_jets = build_basis_derivative_jets(&basis_kind, &radial_distances)?;
810        let id = self.next_entry_id;
811        self.next_entry_id = self.next_entry_id.wrapping_add(1);
812        let entry = Arc::new(CachedDesign {
813            latent_id: latent.latent_id(),
814            fingerprint,
815            basis_digest,
816            latent_metadata_digest,
817            design_context_digest,
818            latent_bits: latent_bits(&latent),
819            cacheable,
820            design: computed.design,
821            hyper_dirs: computed.hyper_dirs,
822            radial_distances,
823            basis_derivative_jets,
824        });
825        if cacheable {
826            insert_persistent_latent_design(Arc::clone(&entry))?;
827        }
828        self.insert(entry, id);
829        self.lookup_inserted(id)
830    }
831
832    fn find_entry(
833        &mut self,
834        latent: &LatentCoordValues,
835        basis_digest: CacheDigest,
836        latent_metadata_digest: CacheDigest,
837        design_context_digest: CacheDigest,
838    ) -> Option<usize> {
839        self.entries.iter().position(|entry| {
840            entry.cached.cacheable
841                && entry.cached.basis_digest == basis_digest
842                && entry.cached.latent_metadata_digest == latent_metadata_digest
843                && entry.cached.design_context_digest == design_context_digest
844                && entry.cached.latent_id == latent.latent_id()
845                && latent_bits_match(latent, &entry.cached.latent_bits)
846        })
847    }
848
849    fn lookup_inserted(&self, id: u64) -> Result<LatentDesignLookup<'_>, EstimationError> {
850        let Some(index) = self.entries.iter().position(|entry| entry.id == id) else {
851            return Err(EstimationError::InvalidInput(
852                "inserted latent design cache entry missing".to_string(),
853            ));
854        };
855        Ok(LatentDesignLookup {
856            cached: self.entries[index].cached.as_ref(),
857            entry_id: self.entries[index].id,
858        })
859    }
860
861    fn insert(&mut self, cached: Arc<CachedDesign>, id: u64) {
862        self.entries.push(LatentDesignCacheEntry {
863            id,
864            cached,
865            last_used: self.clock,
866            iteration: self.iteration,
867        });
868        while self.entries.len() > self.capacity {
869            if let Some(evict_index) = self
870                .entries
871                .iter()
872                .enumerate()
873                .min_by_key(|(_, entry)| (entry.last_used, entry.iteration))
874                .map(|(index, _)| index)
875            {
876                self.entries.remove(evict_index);
877            } else {
878                break;
879            }
880        }
881    }
882}
883
884impl CachedDesign {
885    fn resident_byte_count(&self) -> usize {
886        self.resident_scalar_count()
887            .saturating_mul(std::mem::size_of::<f64>())
888            .saturating_add(
889                self.hyper_dirs
890                    .iter()
891                    .map(DirectionalHyperParam::resident_byte_count)
892                    .sum::<usize>(),
893            )
894    }
895
896    fn resident_scalar_count(&self) -> usize {
897        let mut count = self
898            .design
899            .design
900            .nrows()
901            .saturating_mul(self.design.design.ncols());
902        count = count.saturating_add(
903            self.design
904                .coefficient_lower_bounds
905                .as_ref()
906                .map_or(0, |values| values.len()),
907        );
908        count = count.saturating_add(self.radial_distances.squared.len());
909        count = count.saturating_add(self.radial_distances.distance.len());
910        count = count.saturating_add(
911            self.basis_derivative_jets
912                .phi
913                .as_ref()
914                .map_or(0, |values| values.len()),
915        );
916        count = count.saturating_add(
917            self.basis_derivative_jets
918                .q
919                .as_ref()
920                .map_or(0, |values| values.len()),
921        );
922        count = count.saturating_add(
923            self.basis_derivative_jets
924                .t
925                .as_ref()
926                .map_or(0, |values| values.len()),
927        );
928        count = count.saturating_add(
929            self.basis_derivative_jets
930                .phi_r
931                .as_ref()
932                .map_or(0, |values| values.len()),
933        );
934        count = count.saturating_add(
935            self.basis_derivative_jets
936                .phi_rr
937                .as_ref()
938                .map_or(0, |values| values.len()),
939        );
940        count.saturating_add(usize::from(self.basis_derivative_jets.operator_resident))
941    }
942}
943
944fn lookup_persistent_latent_design(
945    latent: &LatentCoordValues,
946    basis_digest: CacheDigest,
947    latent_metadata_digest: CacheDigest,
948    design_context_digest: CacheDigest,
949    fingerprint: &LatentFingerprint,
950) -> Result<Option<Arc<CachedDesign>>, EstimationError> {
951    let cache = PERSISTENT_LATENT_DESIGN_CACHE
952        .get_or_init(|| Mutex::new(PersistentLatentDesignCache::default()));
953    let mut guard = cache.lock().map_err(|_| {
954        EstimationError::InvalidInput("persistent latent design cache mutex poisoned".to_string())
955    })?;
956    guard.lookup(
957        latent,
958        basis_digest,
959        latent_metadata_digest,
960        design_context_digest,
961        fingerprint,
962    )
963}
964
965fn insert_persistent_latent_design(cached: Arc<CachedDesign>) -> Result<(), EstimationError> {
966    let cache = PERSISTENT_LATENT_DESIGN_CACHE
967        .get_or_init(|| Mutex::new(PersistentLatentDesignCache::default()));
968    let mut guard = cache.lock().map_err(|_| {
969        EstimationError::InvalidInput("persistent latent design cache mutex poisoned".to_string())
970    })?;
971    guard.insert(cached);
972    Ok(())
973}
974
975fn latent_bits(latent: &LatentCoordValues) -> Arc<[u64]> {
976    latent
977        .as_flat()
978        .iter()
979        .map(|value| value.to_bits())
980        .collect::<Vec<_>>()
981        .into()
982}
983
984fn latent_bits_match(latent: &LatentCoordValues, cached_bits: &[u64]) -> bool {
985    latent.as_flat().len() == cached_bits.len()
986        && latent
987            .as_flat()
988            .iter()
989            .zip(cached_bits.iter())
990            .all(|(value, bits)| value.to_bits() == *bits)
991}
992
993fn build_radial_distances(
994    latent: &LatentCoordValues,
995    centers: &Array2<f64>,
996    input_scale: gam_terms::IsotropicScale,
997) -> Result<RadialDistanceMatrices, EstimationError> {
998    // `centers` are standardized and `t` is raw, so the radii are only
999    // commensurate after the same pullback the realized design applies (#2643).
1000    let mut t = latent.as_matrix();
1001    input_scale.standardize(&mut t);
1002    if t.ncols() != centers.ncols() {
1003        return Err(EstimationError::InvalidInput(format!(
1004            "latent design cache center dimension mismatch: latent d={}, centers d={}",
1005            t.ncols(),
1006            centers.ncols()
1007        )));
1008    }
1009    let mut squared = Array2::<f64>::zeros((t.nrows(), centers.nrows()));
1010    let mut distance = Array2::<f64>::zeros((t.nrows(), centers.nrows()));
1011    for row in 0..t.nrows() {
1012        for center in 0..centers.nrows() {
1013            let mut r2 = 0.0_f64;
1014            for axis in 0..t.ncols() {
1015                let delta = t[[row, axis]] - centers[[center, axis]];
1016                r2 += delta * delta;
1017            }
1018            squared[[row, center]] = r2;
1019            distance[[row, center]] = r2.sqrt();
1020        }
1021    }
1022    Ok(RadialDistanceMatrices { squared, distance })
1023}
1024
1025fn build_basis_derivative_jets(
1026    basis_kind: &LatentBasisKind,
1027    distances: &RadialDistanceMatrices,
1028) -> Result<BasisDerivativeJets, EstimationError> {
1029    match basis_kind {
1030        LatentBasisKind::Matern {
1031            input_scale,
1032            length_scale,
1033            nu,
1034            chunk_size,
1035            ..
1036        } => {
1037            if chunk_size.is_some() {
1038                return Ok(BasisDerivativeJets {
1039                    operator_resident: true,
1040                    ..BasisDerivativeJets::empty()
1041                });
1042            }
1043            // `distances` are standardized (see `build_radial_distances`), so
1044            // the range must be too (#2643).
1045            let radial = RadialScalarKind::Matern {
1046                length_scale: input_scale
1047                    .to_standardized_units(*length_scale)
1048                    .standardized_value(),
1049                nu: *nu,
1050            };
1051            let mut phi = Array2::<f64>::zeros(distances.distance.raw_dim());
1052            let mut q = Array2::<f64>::zeros(distances.distance.raw_dim());
1053            let mut t = Array2::<f64>::zeros(distances.distance.raw_dim());
1054            for row in 0..distances.distance.nrows() {
1055                for center in 0..distances.distance.ncols() {
1056                    let (phi_value, q_value, t_value) = radial
1057                        .eval_design_triplet(distances.distance[[row, center]])
1058                        .map_err(EstimationError::from)?;
1059                    phi[[row, center]] = phi_value;
1060                    q[[row, center]] = q_value;
1061                    t[[row, center]] = t_value;
1062                }
1063            }
1064            Ok(BasisDerivativeJets {
1065                phi: Some(phi),
1066                q: Some(q),
1067                t: Some(t),
1068                phi_r: None,
1069                phi_rr: None,
1070                operator_resident: false,
1071            })
1072        }
1073        LatentBasisKind::Duchon { .. } => Ok(BasisDerivativeJets {
1074            operator_resident: true,
1075            ..BasisDerivativeJets::empty()
1076        }),
1077        LatentBasisKind::Sphere { .. }
1078        | LatentBasisKind::PeriodicBspline { .. }
1079        | LatentBasisKind::Pca { .. }
1080        | LatentBasisKind::TensorBspline { .. } => Ok(BasisDerivativeJets {
1081            operator_resident: true,
1082            ..BasisDerivativeJets::empty()
1083        }),
1084    }
1085}