packages feed

fuzzystrmatch-pg-0.1.0.0: src/Data/FuzzyStrMatch/Levenshtein.hs

{- |
Module      : Data.FuzzyStrMatch.Levenshtein
Description : Calculate Levenshtein distances between texts
Copyright   : (c) 2026 Taimoor Zaeem
License     : MIT
Maintainer  : Taimoor Zaeem <taimoorzaeem@gmail.com>
Stability   : Experimental
Portability : Portable

Calculate levenshtein distance between strings
-}
{-# LANGUAGE RecordWildCards #-}
module Data.FuzzyStrMatch.Levenshtein
  ( levenshtein
  , levenshteinWithCosts
  , levenshteinLessEqual
  , levenshteinLessEqualWithCosts
  )
where

import qualified Data.Vector.Unboxed.Mutable as MVU
import qualified Data.Text as T

import Control.Monad.ST (runST, ST)
import Data.Text (Text)
import Prelude

-- Function Signatures taken from:
--   https://www.postgresql.org/docs/current/fuzzystrmatch.html

-- Algorithm:
--   https://en.wikipedia.org/wiki/Wagner–Fischer_algorithm

-- We have 3 popular vector implementation:
-- 1. Data.Vector
--      Linked-List implemenataion, allows thunks/laziness
-- 2. Data.Vector.Unboxed
--      Continuously allocated, much faster for primitive types, strict evaluation
-- 3. Data.Vector.Storable
--      C Compatible memory layout, used in FFI
--
-- Each of these also have a mutable implementation:
-- 1. Data.Vector.Mutable
-- 2. Data.Vector.Unboxed.Mutable
-- 3. Data.Vector.Storable.Mutable
--
-- Considering that we only store Int types and that we need
-- mutability + don't need laziness, the right data structure to
-- use here is "Unboxed Mutable Vector".

-- | Levenshtein distance state
data LState s = LState {
    source  :: Text
  , target  :: Text
  , sLen    :: Int
  , tLen    :: Int
  , prev    :: MVU.MVector s Int
  , curr    :: MVU.MVector s Int
  , insCost :: Int
  , delCost :: Int
  , subCost :: Int
  , maxD    :: Maybe Int
}

-- | Initialize the state
initLState :: Text -> Text -> Int -> Int -> Int -> Maybe Int -> ST s (LState s)
initLState s t ins del sub maxD = do
  let sLen = T.length s
      tLen = T.length t
      -- We always keep the larger string on the x-axis, helpes implementation
      -- For costs, we have to switch ins and del cost when source >= target
      sourceLenGreater = sLen >= tLen
      (s',t',sLen',tLen',ins',del') =
        if sourceLenGreater
          then (t,s,tLen,sLen,del,ins)
          else (s,t,sLen,tLen,ins,del)

  prev <- MVU.new $ tLen' + 1
  -- Init: [0,1,2,..,tLen] for default 1 cost of insertion
  -- Init: [0,2,4,..,tLen * 2] for cost of insertion equal 2 and so on
  _ <- mapFromUpto 0 tLen' (\i -> do
    MVU.write prev i (i * ins')
    return False
    )

  curr <- MVU.new $ tLen' + 1
  return $ LState s' t' sLen' tLen' prev curr ins' del' sub maxD

-- Calculation Matrix For Wagner-Fisher algorithm:
-- ==============================================
--
--  deletion cost, 1 for each by default
--        |
--        | +---+---+---+---+---+---+---+
--        v | S | I | T | T | I | N | G |
--      +---+---+---+---+---+---+---+---+
--      | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |  <-- insertion 1 for each by default
--  +---|---+---+---+---+---+---+---+---+
--  | K | 1 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
--  +---|---+---+---+---+---+---+---+---+
--  | I | 2 | 2 | 1 | 2 | 3 | 4 | 5 | 6 |
--  +---|---+---+---+---+---+---+---+---+
--  | T | 3 | 3 | 2 | 1 | 2 | 3 | 4 | 5 |
--  +---|---+---+---+---+---+---+---+---+
--  | T | 4 | 4 | 3 | 2 | 1 | 2 | 3 | 4 |
--  +---|---+---+---+---+---+---+---+---+
--  | E | 5 | 5 | 4 | 3 | 2 | 2 | 3 | 4 |
--  +---|---+---+---+---+---+---+---+---+
--  | N | 6 | 6 | 5 | 4 | 3 | 3 | 2 | 3 |  <-- This is our answer!!
--  +---|---+---+---+---+---+---+---+---+

-- We only have to use two vectors prev and curr for calculation:
-- In this example:
--
--          +---+---+---+---+---+---+---+
--          | S | I | T | T | I | N | G |
--      +---+---+---+---+---+---+---+---+
--      | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |  <-- prev
--  +---|---+---+---+---+---+---+---+---+
--  | K | 1 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |  <-- curr, becomes prev on next i
--  +---|---+---+---+---+---+---+---+---+
--  | I | 2 | 2 | 1 | 2 | 3 | 4 | 5 | 6 |  <-- becomes curr on next i and so on
--  .
--  .
--  .

-- |
-- Calculate levenshtein distance between source and target string
--
-- @
-- >>> levenshtein "kitten" "sitting"
-- 3
-- @
levenshtein :: Text -> Text -> Int
levenshtein source target = levenshteinWithCosts source target 1 1 1

-- |
-- Calculate the levenshtein distance with different costs
--
-- @
-- >>> levenshteinWithCosts "kitten" "sitting" 1 1 2
-- 5
-- @
--
-- The costs correspond to insertion cost, deletion cost and substitution
-- cost respectively. In the above example, the total cost is @5@ because
-- there are 2 substitions, each with cost 2 and 1 deletion with cost 1.
levenshteinWithCosts :: Text -> Text -> Int -> Int -> Int -> Int
levenshteinWithCosts s t ins del sub = runST $ do

  lState@LState{..} <- initLState s t ins del sub Nothing
  _ <- runLevenshtein lState

  MVU.read prev tLen -- The last one is the answer at target length position

-- | Run Levenshtein algorithm
runLevenshtein :: LState s -> ST s Bool
runLevenshtein lState@LState{..} = do
  exit <- mapFromUpto 0 (sLen - 1) $ calculateCurrentCostAndWrite lState
  if exit then return True else return False

-- | Calculate cost for all cells in the current row
calculateCurrentCostAndWrite :: LState s -> (Int -> ST s Bool)
calculateCurrentCostAndWrite lState@LState{..} i = do
  -- Init: delete cost, goes like 1,2,3... for default 1 cost of deletion
  -- Init: delete cost, goes like 2,4,6... for when cost of deletion is 2 and so on
  MVU.write curr 0 ((i + 1) * delCost)
  exit <- mapFromUpto 0 (tLen - 1) $ calculateCurrentCellCostAndWrite lState i
  MVU.copy prev curr -- Copy current to previous before next iteration
  if exit then return True else return False

-- | Return closure/function/handler to compute the values in a row
--   This reads the current cell and write the minimum cost
--   after calculating insert cost, del cost and sub cost
calculateCurrentCellCostAndWrite :: LState s -> Int -> (Int -> ST s Bool)
calculateCurrentCellCostAndWrite LState{..} i j = do
  subCost' <- MVU.read prev j
  insCost' <- MVU.read prev (j + 1)
  delCost' <- MVU.read curr j

  -- NOTE: Although T.index is unsafe, safety can be guaranteed through
  --       outside bounds checking.

  -- If the character being compared are not equal, then it costs
  -- substitution cost.
  let curCost = if T.index source i == T.index target j then 0 else subCost
      minCost  = minimum [ insCost' + insCost, delCost' + delCost, subCost' + curCost ]

  MVU.write curr (j + 1) minCost

  return $ case maxD of
    Nothing -> False
    Just n  -> minCost > n && i == (j+1) -- if we are on the diagonal and the minCost > maxD, we break

-- | Map over the vector from index i to j and apply f on i
mapFromUpto :: Int -> Int -> (Int -> ST s Bool) -> ST s Bool
-- "s" is the type signature is a "Phantom Type" which is needed by GHC
-- state monad for type checking reasons. At runtime, "s" is nothing. So,
-- dont' worry about it too much
mapFromUpto i j f
  | i > j     = return False
  | otherwise = do
    exit <- f i
    if exit then return True else mapFromUpto (i+1) j f


-- ========================
-- Threshold Optimization:
--  https://en.wikipedia.org/wiki/Wagner-Fischer_algorithm#Possible_modifications
-- =========================

-- |
-- Calculate the levenshtein distance with an upper bound. If the cost is
-- more than maximum, it returns @Nothing@.
--
-- @
-- >>> levenshteinLessEqual "kitten" "sitting" 3
-- Just 3
-- @
--
-- @
-- >>> levenshteinLessEqual "kitten" "sitting" 2
-- Nothing
-- @
--
-- It is important to note that this doesn't necessarily calculate the
-- complete cost and exits early as soon as the minimum costs exceeds
-- bound, hence much faster.
levenshteinLessEqual :: Text -> Text -> Int -> Maybe Int
levenshteinLessEqual source target = levenshteinLessEqualWithCosts source target 1 1 1

-- |
-- Calculate the levenshtein distance with an upper bound with different
-- costs. It works same as `levenshteinWithCosts` but with an upper bound.
levenshteinLessEqualWithCosts :: Text -> Text -> Int -> Int -> Int -> Int -> Maybe Int
levenshteinLessEqualWithCosts s t ins del sub maxD' = runST $ do

  lState@LState{..} <- initLState s t ins del sub (Just maxD')
  exit <- runLevenshtein lState

  if exit -- If exit if True, that means we have broken early, return Nothing
    then return Nothing
    else do
      -- An edge case we need to check, if the iterations are complete,
      -- we calculate the cost all the way for last row and then check
      -- if this is greater than maxD
      --        | +---+---+---+---+---+---+---+
      --        v | S | I | T | T | I | N | G |
      --      +---+---+---+---+---+---+---+---+
      --      | 0 |   |   |   |   |   |   |   |
      --  +---|---+---+---+---+---+---+---+---+
      --  | K | 1 | 1 |   |   |   |   |   |   |
      --  +---|---+---+---+---+---+---+---+---+
      --  | I | 2 | 2 | 1 | 2 | 3 | 4 | 5 | 6 | <- the diagnoal ends with cost 1
      --  +---|---+---+---+---+---+---+---+---+    but inserstion costs are added
      --
      -- Transpose case for this:
      --        | +---+---+---+
      --        v | S | I | T | -- We DON'T need to implement this case,
      --      +---+---+---+---+ -- because we always choose the longer string
      --      | 0 |   |   |   | -- to be target INTERNALLY.
      --  +---|---+---+---+---+
      --  | K | 1 | 1 |   |   |
      --  +---|---+---+---+---+
      --  | I | 2 | 2 | 1 | 2 |
      --  +---|---+---+---+---+
      --  | T | 3 | 3 | 2 | 1 | <- exit here, no need to calculate further
      --  +---|---+---+---+---+
      --  | T | 4 | 4 | 3 | 2 |
      --  +---|---+---+---+---+
      --  | E | 5 | 5 | 4 | 3 |
      --  +---|---+---+---+---+
      --  | N | 6 | 6 | 5 | 4 |  <-- This is our answer, but we need to exit early
      --  +---|---+---+---+---+
      d <- MVU.read prev tLen
      return $ if d > maxD' then Nothing else Just d