//! Thread-local LRU cache for fluid property queries. //! //! Avoids redundant backend calls without mutex contention by using //! per-thread storage. Cache keys use quantized state values since f64 //! does not implement Hash. //! //! # Quantization Strategy //! //! State values (P, T, h, s, x) are quantized to 1e-9 relative precision //! for cache key derivation. Solver iterations often repeat the same //! (P,T) or (P,h) states; quantization should not lose cache hits for //! typical thermodynamic ranges (P: 1e3–1e7 Pa, T: 200–600 K). use crate::mixture::Mixture; use crate::types::{FluidId, FluidState, Property}; use lru::LruCache; use std::cell::RefCell; use std::hash::{Hash, Hasher}; use std::num::NonZeroUsize; /// Default cache capacity (entries). LRU eviction when exceeded. pub const DEFAULT_CACHE_CAPACITY: usize = 10_000; const DEFAULT_CAP_NONZERO: NonZeroUsize = NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).unwrap(); /// Quantization factor: values rounded to 1e-9 relative. /// (v * 1e9).round() as i64 for Hash-compatible key. /// /// Overflow safety (Story 0.7, 2026-07-19 finite-poisoning incident): for /// |v| > 9e9 the product `v * 1e9` exceeds `i64::MAX` and wraps to garbage, /// making extreme solver trial states (blown iterates, h ~ 1e13) collide /// with legitimate keys. Those states fall back to exact bit hashing — they /// do not need near-state hits, they need to never collide. #[inline] fn quantize(v: f64) -> i64 { if v.is_nan() { #[cfg(debug_assertions)] eprintln!("[WARN] quantize: NaN value encountered, mapping to 0"); 0 } else if v.is_infinite() { #[cfg(debug_assertions)] eprintln!("[WARN] quantize: Infinite value encountered, mapping to 0"); 0 } else if v.abs() > 9.0e9 { // Exact, sign-masked bit pattern (no quantization) for huge values. (v.to_bits() & 0x7fff_ffff_ffff_ffff) as i64 } else { (v * 1e9).round() as i64 } } /// Cache key for fluid property lookups. /// /// Uses quantized state values since f64 does not implement Hash. /// Includes backend_id so multiple CachedBackend instances don't mix results. /// For mixtures, includes a hash of the mixture composition. #[derive(Clone, Debug)] pub struct CacheKey { backend_id: usize, fluid: String, property: Property, variant: u8, p_quantized: i64, second_quantized: i64, mixture_hash: Option, } impl PartialEq for CacheKey { fn eq(&self, other: &Self) -> bool { self.backend_id == other.backend_id && self.fluid == other.fluid && self.property == other.property && self.variant == other.variant && self.p_quantized == other.p_quantized && self.second_quantized == other.second_quantized && self.mixture_hash == other.mixture_hash } } impl Eq for CacheKey {} impl Hash for CacheKey { fn hash(&self, state: &mut H) { self.backend_id.hash(state); self.fluid.hash(state); self.property.hash(state); self.variant.hash(state); self.p_quantized.hash(state); self.second_quantized.hash(state); self.mixture_hash.hash(state); } } impl CacheKey { /// Build a cache key from fluid, property, state, and backend id. pub fn new(backend_id: usize, fluid: &FluidId, property: Property, state: &FluidState) -> Self { let (p, second, variant, mixture_hash) = match state { FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin(), 0u8, None), FluidState::PressureEnthalpy(p, h) => (p.to_pascals(), h.to_joules_per_kg(), 1u8, None), FluidState::PressureEntropy(p, s) => { (p.to_pascals(), s.to_joules_per_kg_kelvin(), 2u8, None) } FluidState::PressureQuality(p, x) => (p.to_pascals(), x.value(), 3u8, None), FluidState::PressureTemperatureMixture(p, t, ref m) => { (p.to_pascals(), t.to_kelvin(), 4u8, Some(mix_hash(m))) } FluidState::PressureEnthalpyMixture(p, h, ref m) => { (p.to_pascals(), h.to_joules_per_kg(), 5u8, Some(mix_hash(m))) } FluidState::PressureQualityMixture(p, x, ref m) => { (p.to_pascals(), x.value(), 6u8, Some(mix_hash(m))) } }; CacheKey { backend_id, fluid: fluid.0.clone(), property, variant, p_quantized: quantize(p), second_quantized: quantize(second), mixture_hash, } } } /// Compute a simple hash for a mixture for cache key purposes. fn mix_hash(mixture: &Mixture) -> u64 { use std::collections::hash_map::DefaultHasher; let mut hasher = DefaultHasher::new(); mixture.hash(&mut hasher); hasher.finish() } thread_local! { static CACHE: RefCell> = RefCell::new( LruCache::new(DEFAULT_CAP_NONZERO) ); } /// Get a value from the thread-local cache (no allocation on key build for hot path). pub fn cache_get( backend_id: usize, fluid: &FluidId, property: Property, state: &FluidState, ) -> Option { let key = CacheKey::new(backend_id, fluid, property, state); CACHE.with(|c| { c.try_borrow_mut() .ok() .and_then(|mut cache| cache.get(&key).copied()) }) } /// Insert a value into the thread-local cache. pub fn cache_insert( backend_id: usize, fluid: &FluidId, property: Property, state: &FluidState, value: f64, ) { let key = CacheKey::new(backend_id, fluid, property, state); CACHE.with(|c| { if let Ok(mut cache) = c.try_borrow_mut() { cache.put(key, value); } // Silently ignore if borrow fails (cache miss is acceptable) }); } /// Clear the thread-local cache (e.g. at solver iteration boundaries). pub fn cache_clear() { CACHE.with(|c| { if let Ok(mut cache) = c.try_borrow_mut() { cache.clear(); } // Silently ignore if borrow fails }); } /// Resize the thread-local cache capacity. pub fn cache_resize(capacity: NonZeroUsize) { CACHE.with(|c| { if let Ok(mut cache) = c.try_borrow_mut() { cache.resize(capacity); } // Silently ignore if borrow fails }); } #[cfg(test)] mod tests { use super::*; use entropyk_core::{Pressure, Temperature}; #[test] fn test_cache_key_quantization() { let fluid = FluidId::new("R134a"); let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)); let key1 = CacheKey::new(0, &fluid, Property::Density, &state); let key2 = CacheKey::new(0, &fluid, Property::Density, &state); assert_eq!(key1, key2); // Equal keys must have same hash (for HashMap use) use std::collections::hash_map::DefaultHasher; let mut h1 = DefaultHasher::new(); let mut h2 = DefaultHasher::new(); key1.hash(&mut h1); key2.hash(&mut h2); assert_eq!(h1.finish(), h2.finish()); } #[test] fn test_cache_key_different_states() { let fluid = FluidId::new("R134a"); let state1 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)); let state2 = FluidState::from_pt(Pressure::from_bar(2.0), Temperature::from_celsius(25.0)); let key1 = CacheKey::new(0, &fluid, Property::Density, &state1); let key2 = CacheKey::new(0, &fluid, Property::Density, &state2); assert_ne!(key1, key2); } #[test] fn test_lru_eviction() { use std::num::NonZeroUsize; cache_clear(); cache_resize(NonZeroUsize::new(2).expect("2 is non-zero")); let fluid = FluidId::new("R134a"); let state1 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(20.0)); let state2 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)); let state3 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(30.0)); cache_insert(0, &fluid, Property::Density, &state1, 1000.0); cache_insert(0, &fluid, Property::Density, &state2, 1100.0); cache_insert(0, &fluid, Property::Density, &state3, 1200.0); assert!(cache_get(0, &fluid, Property::Density, &state1).is_none()); assert_eq!( cache_get(0, &fluid, Property::Density, &state2), Some(1100.0) ); assert_eq!( cache_get(0, &fluid, Property::Density, &state3), Some(1200.0) ); cache_resize(NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("capacity is non-zero")); } #[test] fn test_quantize_extreme_values_do_not_collide_with_normal_keys() { // 2026-07-19 incident: `v * 1e9` overflows i64 for |v| > ~9e9, wrapping // blown-up trial states onto legitimate keys (finite poisoning). let normal_a = quantize(100_000.0); let normal_b = quantize(420_000.0); for extreme in [1.0e13, -1.0e13, 3.7e15, f64::MAX / 2.0] { let q = quantize(extreme); assert_ne!(q, normal_a, "extreme {extreme} collides with 1e5"); assert_ne!(q, normal_b, "extreme {extreme} collides with 4.2e5"); } // Distinct extremes get distinct keys (exact bit hashing). assert_ne!(quantize(1.0e13), quantize(1.0e13 * 1.000001)); // Normal quantization unchanged. assert_eq!(quantize(1.5), quantize(1.5)); assert_ne!(quantize(1.5), quantize(1.5 + 1e-8)); } #[test] fn test_cache_key_different_backends() { let fluid = FluidId::new("R134a"); let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)); let key1 = CacheKey::new(0, &fluid, Property::Density, &state); let key2 = CacheKey::new(1, &fluid, Property::Density, &state); assert_ne!(key1, key2); } }