claw-core 0.1.2

Embedded local database engine for ClawDB — an agent-native cognitive database
Documentation
//! In-memory LRU cache layer for claw-core.
//!
//! [`ClawCache`] is a simple, generic LRU (Least-Recently-Used) cache that
//! sits in front of the SQLite store for frequently accessed records. Cache
//! capacity is expressed in a configurable maximum number of entries; actual
//! memory usage depends on the size of the cached values.
//!
//! This module provides the cache infrastructure only. The individual store
//! modules in [`crate::store`] decide which records to cache and for how long.

use std::collections::HashMap;
use std::hash::Hash;

use crate::error::{ClawError, ClawResult};

/// A minimal LRU cache with a fixed maximum capacity.
///
/// When the cache is full and a new entry is inserted, the least-recently-used
/// entry is evicted.  All operations are O(1) amortised.
///
/// # Type Parameters
///
/// * `K` — cache key type; must implement [`Eq`] + [`Hash`] + [`Clone`].
/// * `V` — cached value type.
#[derive(Debug)]
pub struct ClawCache<K, V> {
    capacity: usize,
    /// Insertion-order tracking for LRU eviction.
    order: std::collections::VecDeque<K>,
    store: HashMap<K, V>,
}

impl<K, V> ClawCache<K, V>
where
    K: Eq + Hash + Clone,
{
    /// Create a new cache with the given maximum `capacity` (number of entries).
    ///
    /// # Errors
    ///
    /// Returns [`ClawError::Cache`] if `capacity` is zero.
    pub fn new(capacity: usize) -> ClawResult<Self> {
        if capacity == 0 {
            return Err(ClawError::Cache("cache capacity must be >= 1".to_string()));
        }
        Ok(ClawCache {
            capacity,
            order: std::collections::VecDeque::new(),
            store: HashMap::with_capacity(capacity),
        })
    }

    /// Insert `value` under `key`. If the cache is full, the LRU entry is
    /// evicted first.
    pub fn insert(&mut self, key: K, value: V) {
        if self.store.contains_key(&key) {
            self.order.retain(|k| k != &key);
        } else if self.store.len() >= self.capacity {
            if let Some(lru_key) = self.order.pop_front() {
                self.store.remove(&lru_key);
            }
        }
        self.order.push_back(key.clone());
        self.store.insert(key, value);
    }

    /// Return a reference to the cached value for `key`, or `None` if not
    /// present. Accessing an entry marks it as most-recently used.
    pub fn get(&mut self, key: &K) -> Option<&V> {
        if self.store.contains_key(key) {
            self.order.retain(|k| k != key);
            self.order.push_back(key.clone());
            self.store.get(key)
        } else {
            None
        }
    }

    /// Remove and return the value for `key`, if present.
    pub fn remove(&mut self, key: &K) -> Option<V> {
        let value = self.store.remove(key);
        if value.is_some() {
            self.order.retain(|k| k != key);
        }
        value
    }

    /// Invalidate (remove) the value for `key`, if present.
    pub fn invalidate(&mut self, key: &K) {
        if self.store.remove(key).is_some() {
            self.order.retain(|k| k != key);
        }
    }

    /// Remove all entries from the cache.
    pub fn clear(&mut self) {
        self.store.clear();
        self.order.clear();
    }

    /// Return the number of entries currently in the cache.
    pub fn len(&self) -> usize {
        self.store.len()
    }

    /// Return `true` if the cache contains no entries.
    pub fn is_empty(&self) -> bool {
        self.store.is_empty()
    }
}

/// Statistics collected by a [`ClawCache`] instance.
///
/// Retrieve via [`crate::ClawEngine::cache_stats`].
///
/// # Example
///
/// ```rust
/// use claw_core::CacheStats;
/// let stats = CacheStats::new();
/// assert_eq!(stats.hit_ratio(), 0.0);
/// ```
#[derive(Debug, Clone, Default)]
pub struct CacheStats {
    /// Number of cache lookups that found an entry.
    pub hit_count: u64,
    /// Number of cache lookups that found no entry.
    pub miss_count: u64,
    /// Total number of entries inserted into the cache.
    pub insert_count: u64,
    /// Total number of entries evicted from the cache.
    pub evict_count: u64,
    // Rolling window (private) for the last 1 000 lookups.
    rolling: std::collections::VecDeque<bool>,
}

impl CacheStats {
    /// Create a zeroed [`CacheStats`].
    ///
    /// # Example
    ///
    /// ```rust
    /// use claw_core::CacheStats;
    /// let s = CacheStats::new();
    /// assert_eq!(s.hit_count, 0);
    /// ```
    pub fn new() -> Self {
        CacheStats {
            hit_count: 0,
            miss_count: 0,
            insert_count: 0,
            evict_count: 0,
            rolling: std::collections::VecDeque::with_capacity(1001),
        }
    }

    /// Record a cache hit, updating both the lifetime counter and the rolling
    /// 1 000-op window used by [`CacheStats::rolling_hit_rate`].
    pub(crate) fn record_hit(&mut self) {
        self.hit_count += 1;
        if self.rolling.len() >= 1000 {
            self.rolling.pop_front();
        }
        self.rolling.push_back(true);
    }

    /// Record a cache miss, updating both the lifetime counter and the rolling
    /// 1 000-op window used by [`CacheStats::rolling_hit_rate`].
    pub(crate) fn record_miss(&mut self) {
        self.miss_count += 1;
        if self.rolling.len() >= 1000 {
            self.rolling.pop_front();
        }
        self.rolling.push_back(false);
    }

    /// Cache hit rate over the most recent 1 000 lookups (0.0 – 1.0).
    ///
    /// Returns `0.0` when fewer than one lookup has been recorded.
    pub fn rolling_hit_rate(&self) -> f64 {
        if self.rolling.is_empty() {
            return 0.0;
        }
        let hits = self.rolling.iter().filter(|&&h| h).count();
        hits as f64 / self.rolling.len() as f64
    }

    /// Cache hit ratio as a value between `0.0` and `1.0`.
    ///
    /// Returns `0.0` if no lookups have been performed yet.
    ///
    /// # Example
    ///
    /// ```rust
    /// use claw_core::CacheStats;
    /// let mut s = CacheStats::new();
    /// s.hit_count = 3;
    /// s.miss_count = 1;
    /// assert!((s.hit_ratio() - 0.75).abs() < f64::EPSILON);
    /// ```
    pub fn hit_ratio(&self) -> f64 {
        let total = self.hit_count + self.miss_count;
        if total == 0 {
            0.0
        } else {
            self.hit_count as f64 / total as f64
        }
    }
}