packages feed

lfudacaching-0.1.0.0: src/Data/LfudaCache.hs

{-# LANGUAGE StrictData #-}
{-|
Module      : Data.LfudaCache
Description : Pure LFUDA, GDSF, and LFU cache implementations
Copyright   : (c) 2026 philippedev101
License     : Apache-2.0
Maintainer  : philippedev101@gmail.com
Stability   : experimental

Pure, immutable cache with three eviction policies: __LFUDA__, __GDSF__, and __LFU__.

== Eviction policies

* __LFUDA__ (Least Frequently Used with Dynamic Aging) — combines access
  frequency with a global /age/ counter that advances on every eviction.
  New entries start with @frequency + age@ as their priority, which prevents
  long-lived but rarely accessed items from permanently occupying the cache
  (a common weakness of plain LFU).

* __GDSF__ (Greedy Dual-Size Frequency) — extends LFUDA by factoring in
  entry size: @frequency + age × size@. Useful when cached values have
  varying costs.

* __LFU__ (Least Frequently Used) — evicts the entry with the lowest access
  frequency. No aging is applied, so frequently accessed items are never
  evicted regardless of how long ago they were last accessed.

== Quick start

@
import Data.LfudaCache
import Prelude hiding ('lookup')

example :: (Maybe String, LfudaCache String String)
example =
  let cache  = 'newLFUDA' 100
      cache' = 'insert' \"hello\" \"world\" cache
  in  case 'lookup' \"hello\" cache' of
        Just (val, cache'') -> (Just val, cache'')
        Nothing             -> (Nothing, cache')
@

== Complexity

All operations are /O(log n)/ in the number of cached entries, backed by a
hash-priority search queue ('Data.HashPSQ.HashPSQ').

== Lookup vs Peek

'lookup' increments the entry's access frequency (affecting future eviction
priority). 'peek' returns the value without any side effect on frequency —
useful for monitoring or read-only inspection.
-}
module Data.LfudaCache
  ( -- * Cache type
    LfudaCache
  , CachePolicy(..)
  , Age

  -- * Construction
  , newLFUDA
  , newGDSF
  , newLFU
  , newCache

  -- * Insertion
  , insert
  , insertView

  -- * Lookup
  , lookup
  , peek
  , contains

  -- * Deletion
  , remove
  , purge

  -- * Size and metadata
  , keys
  , size
  , age
  ) where

import Control.DeepSeq (NFData)
import Data.Hashable (Hashable)
import Data.Int (Int64)
import Data.Maybe (isNothing)
import GHC.Generics (Generic)
import GHC.Types (Type)
import Prelude hiding (lookup)
import qualified Data.HashPSQ as HashPSQ


-- | Eviction policy. See the module documentation for a description of each.
type CachePolicy :: Type
data CachePolicy
  = LFUDA  -- ^ Least Frequently Used with Dynamic Aging.
           -- Priority = @frequency + age@.
  | GDSF   -- ^ Greedy Dual-Size Frequency.
           -- Priority = @frequency + age × size@.
  | LFU    -- ^ Plain Least Frequently Used (no aging).
           -- Priority = @frequency@.
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

-- | Access count for a cache entry. Incremented on every 'lookup' hit.
type Frequency :: Type
type Frequency = Int64

-- | Cache age counter. Under LFUDA\/GDSF this advances on every eviction,
-- ensuring that newly inserted entries are not immediately evicted just
-- because older entries accumulated high frequency counts.
type Age :: Type
type Age = Int64

-- | Internal priority value computed from 'Frequency', 'Size', and 'Age'
-- according to the active 'CachePolicy'. The entry with the /lowest/
-- priority is evicted first.
type Priority :: Type
type Priority = Int64

-- | Logical size of a cache entry (used by the 'GDSF' policy).
-- Currently fixed at @1@ for every entry.
type Size :: Type
type Size = Int64

-- | Internal result of an eviction attempt (not exported).
type EvictionResult :: Type -> Type -> Type
type role EvictionResult representational representational
data EvictionResult k v =
  EvictionResult
  { evicted :: !Bool          -- ^ Whether an eviction occurred
  , evictedKey :: !(Maybe k)  -- ^ The key that was evicted (if any)
  , evictedValue :: !(Maybe v)  -- ^ The value that was evicted (if any)
  } deriving stock (Show, Eq, Generic)
  deriving anyclass (NFData)

-- | An immutable, bounded cache parameterised by key type @k@ and value
-- type @v@. The eviction strategy is determined by the 'CachePolicy' chosen
-- at construction time.
--
-- The cache supports 'Functor', 'Foldable', and 'Traversable' over values,
-- as well as 'Eq', 'Show', and 'NFData'.
type LfudaCache :: Type -> Type -> Type
type role LfudaCache representational representational
data LfudaCache k v = LfudaCache
  { lfudaCapacity :: {-# UNPACK #-} !Int       -- ^ The maximum number of elements in the cache
  , lfudaSize     :: {-# UNPACK #-} !Int       -- ^ The current number of elements in the cache
  , lfudaAge      :: {-# UNPACK #-} !Age       -- ^ The current age of the cache
  , lfudaPolicy   :: !CachePolicy              -- ^ The cache eviction policy to use
  , lfudaQueue    :: !(HashPSQ.HashPSQ k Priority (Frequency, Size, v)) -- ^ Underlying priority queue
  } deriving stock (Generic)

instance (Show k, Show v) => Show (LfudaCache k v) where
  show c = "LfudaCache { capacity=" ++ show (lfudaCapacity c) ++
           ", size=" ++ show (lfudaSize c) ++
           ", age=" ++ show (lfudaAge c) ++
           ", policy=" ++ show (lfudaPolicy c) ++
           ", entries=" ++ show (lfudaSize c) ++ " }"

instance (Eq k, Eq v, Hashable k, Ord k) => Eq (LfudaCache k v) where
  c1 == c2 = lfudaCapacity c1 == lfudaCapacity c2 &&
             lfudaSize c1 == lfudaSize c2 &&
             lfudaAge c1 == lfudaAge c2 &&
             lfudaPolicy c1 == lfudaPolicy c2 &&
             lfudaQueue c1 == lfudaQueue c2

instance (NFData k, NFData v) => NFData (LfudaCache k v)

-- Manual implementation of Functor
instance (Hashable k, Ord k) => Functor (LfudaCache k) where
  {-# INLINABLE fmap #-}
  fmap f (LfudaCache cap sizeVal ageVal policy queue) =
    LfudaCache cap sizeVal ageVal policy (mapQueue f queue)
    where
      mapQueue :: forall v v'. (v -> v') -> HashPSQ.HashPSQ k Priority (Frequency, Size, v) -> HashPSQ.HashPSQ k Priority (Frequency, Size, v')
      mapQueue g = HashPSQ.fromList . map mapEntry . HashPSQ.toList
        where
          mapEntry :: (k, Priority, (Frequency, Size, v)) -> (k, Priority, (Frequency, Size, v'))
          mapEntry (k, p, (freq, s, v)) = (k, p, (freq, s, g v))

-- Manual implementation of Foldable
instance (Hashable k, Ord k) => Foldable (LfudaCache k) where
  {-# INLINABLE foldr #-}
  foldr f z = foldr (\(_, _, (_, _, v)) acc -> f v acc) z . HashPSQ.toList . lfudaQueue

-- Manual implementation of Traversable
instance (Hashable k, Ord k) => Traversable (LfudaCache k) where
  {-# INLINABLE traverse #-}
  traverse f (LfudaCache cap sizeVal ageVal policy queue) =
    LfudaCache cap sizeVal ageVal policy <$> traverseQueue f queue
    where
      traverseQueue :: forall f v v'. Applicative f =>
                     (v -> f v') ->
                     HashPSQ.HashPSQ k Priority (Frequency, Size, v) ->
                     f (HashPSQ.HashPSQ k Priority (Frequency, Size, v'))
      traverseQueue g = fmap HashPSQ.fromList . traverse transformEntry . HashPSQ.toList
        where
          transformEntry :: (k, Priority, (Frequency, Size, v)) -> f (k, Priority, (Frequency, Size, v'))
          transformEntry (k, p, (freq, s, v)) = (\v' -> (k, p, (freq, s, v'))) <$> g v

-- | Create a new empty cache with the given maximum capacity and eviction
-- policy. Calls 'error' if @capacity < 1@.
--
-- >>> size (newCache 100 LFUDA)
-- 0
{-# INLINABLE newCache #-}
newCache :: Int -> CachePolicy -> LfudaCache k v
newCache capacity policy
  | capacity < 1 = error "LfudaCache.new: capacity < 1"
  | otherwise    = LfudaCache capacity 0 0 policy HashPSQ.empty

-- | @'newLFUDA' cap@ — shorthand for @'newCache' cap 'LFUDA'@.
{-# INLINABLE newLFUDA #-}
newLFUDA :: Int -> LfudaCache k v
newLFUDA capacity = newCache capacity LFUDA

-- | @'newGDSF' cap@ — shorthand for @'newCache' cap 'GDSF'@.
{-# INLINABLE newGDSF #-}
newGDSF :: Int -> LfudaCache k v
newGDSF capacity = newCache capacity GDSF

-- | @'newLFU' cap@ — shorthand for @'newCache' cap 'LFU'@.
{-# INLINABLE newLFU #-}
newLFU :: Int -> LfudaCache k v
newLFU capacity = newCache capacity LFU

-- | Compute the eviction priority for an entry under the given policy.
-- Lower priority ⇒ evicted first.
{-# INLINE calculatePriority #-}
calculatePriority :: CachePolicy -> Frequency -> Size -> Age -> Priority
calculatePriority LFUDA freq _ ageValue = freq + ageValue
calculatePriority GDSF  freq sizeValue ageValue = freq + ageValue * sizeValue
calculatePriority LFU   freq _ _ = freq

-- | Common logic for insert operations
{-# INLINABLE prepareInsert #-}
prepareInsert :: (Hashable k, Ord k) => k -> v -> LfudaCache k v
              -> (Bool, LfudaCache k v)
prepareInsert key val c =
  let initialFreq :: Frequency
      initialFreq = 1
      entrySize :: Size
      entrySize = 1  -- Default size (could be parameterized in future)
      priority = calculatePriority (lfudaPolicy c) initialFreq entrySize (lfudaAge c)
      (mbOldVal, queue') = HashPSQ.insertView key priority (initialFreq, entrySize, val) (lfudaQueue c)
      sizeIncrease = isNothing mbOldVal
  in (sizeIncrease, c { lfudaSize  = if sizeIncrease
                                     then lfudaSize c + 1
                                     else lfudaSize c
                       , lfudaQueue = queue'
                       })

-- | Restore 'LfudaCache' invariants by evicting elements if cache exceeds capacity
-- Returns (eviction result, updated cache)
{-# INLINABLE trim #-}
trim :: (Hashable k, Ord k) => LfudaCache k v -> (EvictionResult k v, LfudaCache k v)
trim c
  | lfudaSize c <= lfudaCapacity c =
      (EvictionResult False Nothing Nothing, c)
  | otherwise =
      case HashPSQ.findMin (lfudaQueue c) of
        Nothing ->
          (EvictionResult False Nothing Nothing, c)  -- Should not happen in practice
        Just (k, _, (freq, _, v)) ->
          let newAge = case lfudaPolicy c of
                LFU -> lfudaAge c     -- No aging for LFU
                _   -> freq           -- Set age to frequency
              c' = c { lfudaSize  = lfudaSize c - 1
                     , lfudaAge   = newAge
                     , lfudaQueue = HashPSQ.deleteMin (lfudaQueue c)
                     }
          in (EvictionResult True (Just k) (Just v), c')

-- | Insert a key–value pair. If the key already exists its value is
-- replaced and its frequency is reset to @1@. When the cache is at
-- capacity the lowest-priority entry is evicted first.
--
-- Use 'insertView' if you need to know /which/ entry was evicted.
--
-- /O(log n)/
--
-- >>> let c = insert "a" 1 (newLFUDA 2)
-- >>> size c
-- 1
{-# INLINABLE insert #-}
insert :: (Hashable k, Ord k) => k -> v -> LfudaCache k v -> LfudaCache k v
insert key val c =
  let (_, c') = prepareInsert key val c
      (_, c'') = trim c'
  in c''

-- | Like 'insert', but also returns the evicted entry (if any) as
-- @'Just' (key, value)@, or 'Nothing' when no eviction was necessary.
--
-- /O(log n)/
--
-- >>> let (ev1, c)  = insertView "a" 1 (newLFUDA 1)
-- >>> ev1
-- Nothing
-- >>> let (ev2, _) = insertView "b" 2 c
-- >>> ev2
-- Just ("a",1)
{-# INLINABLE insertView #-}
insertView :: (Hashable k, Ord k) => k -> v -> LfudaCache k v -> (Maybe (k, v), LfudaCache k v)
insertView key val c =
  let (_, c') = prepareInsert key val c
      (evictionResult, c'') = trim c'
  in case (evictedKey evictionResult, evictedValue evictionResult) of
       (Just k, Just v) -> (Just (k, v), c'')
       _ -> (Nothing, c'')

-- | Look up a key, returning its value and an updated cache with the
-- entry's frequency incremented. Returns 'Nothing' on a cache miss.
--
-- Use 'peek' if you do not want the frequency bump.
--
-- /O(log n)/
--
-- >>> let c = insert "a" 1 (newLFUDA 2)
-- >>> fmap fst (lookup "a" c)
-- Just 1
-- >>> lookup "z" c
-- Nothing
{-# INLINABLE lookup #-}
lookup :: (Hashable k, Ord k) => k -> LfudaCache k v -> Maybe (v, LfudaCache k v)
lookup k c =
  case HashPSQ.lookup k (lfudaQueue c) of
    Nothing -> Nothing
    Just (_, (freq, entrySize, v)) ->
      let newFreq = freq + 1
          newPriority = calculatePriority (lfudaPolicy c) newFreq entrySize (lfudaAge c)
          c' = c { lfudaQueue = HashPSQ.insert k newPriority (newFreq, entrySize, v) (lfudaQueue c) }
      in Just (v, c')

-- | Test whether a key is present in the cache. Does /not/ affect
-- the entry's frequency.
--
-- /O(log n)/
--
-- >>> let c = insert "a" 1 (newLFUDA 2)
-- >>> contains "a" c
-- True
-- >>> contains "z" c
-- False
{-# INLINABLE contains #-}
contains :: (Hashable k, Ord k) => k -> LfudaCache k v -> Bool
contains k = HashPSQ.member k . lfudaQueue

-- | Retrieve a value without incrementing its access frequency.
-- Useful for read-only inspection, monitoring, or debugging.
--
-- /O(log n)/
--
-- >>> let c = insert "a" 1 (newLFUDA 2)
-- >>> peek "a" c
-- Just 1
-- >>> peek "z" c
-- Nothing
{-# INLINABLE peek #-}
peek :: (Hashable k, Ord k) => k -> LfudaCache k v -> Maybe v
peek k c = (\(_, (_, _, v)) -> v) <$> HashPSQ.lookup k (lfudaQueue c)

-- | Remove a key from the cache. Returns the cache unchanged if the key
-- is not present.
--
-- /O(log n)/
--
-- >>> let c = insert "a" 1 (newLFUDA 2)
-- >>> size (remove "a" c)
-- 0
-- >>> size (remove "z" c)
-- 1
{-# INLINABLE remove #-}
remove :: (Hashable k, Ord k) => k -> LfudaCache k v -> LfudaCache k v
remove k c =
  case HashPSQ.lookup k (lfudaQueue c) of
    Nothing -> c
    Just _  -> c { lfudaSize  = lfudaSize c - 1
                 , lfudaQueue = HashPSQ.delete k (lfudaQueue c)
                 }

-- | Remove all entries from the cache, resetting 'size' to @0@.
-- The capacity and policy are preserved.
--
-- /O(1)/
--
-- >>> size (purge (insert "a" 1 (newLFUDA 2)))
-- 0
{-# INLINABLE purge #-}
purge :: LfudaCache k v -> LfudaCache k v
purge c = c { lfudaSize  = 0
            , lfudaQueue = HashPSQ.empty
            }

-- | The current age of the cache. Under 'LFUDA' and 'GDSF' the age
-- advances each time an entry is evicted; under 'LFU' it stays at @0@.
--
-- /O(1)/
{-# INLINABLE age #-}
age :: LfudaCache k v -> Age
age = lfudaAge

-- | All keys currently in the cache, ordered from highest to lowest
-- eviction priority (i.e. the entry most likely to survive eviction
-- comes first).
--
-- /O(n log n)/
{-# INLINABLE keys #-}
keys :: (Hashable k, Ord k) => LfudaCache k v -> [k]
keys = map (\(k, _, _) -> k) . reverse . HashPSQ.toList . lfudaQueue

-- | The number of entries currently stored in the cache.
--
-- /O(1)/
{-# INLINABLE size #-}
size :: LfudaCache k v -> Int
size = lfudaSize