packages feed

tiktoken-1.0.1: src/Tiktoken.hs

{-# LANGUAGE BlockArguments        #-}
{-# LANGUAGE DeriveAnyClass        #-}
{-# LANGUAGE DeriveGeneric         #-}
{-# LANGUAGE DerivingStrategies    #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns        #-}
{-# LANGUAGE NumericUnderscores    #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE OverloadedLists       #-}
{-# LANGUAGE QuasiQuotes           #-}
{-# LANGUAGE RecordWildCards       #-}

-- | You can use this module to convert back and forth between a `ByteString`
--   and its corresponding tokens using an existing encoding like `cl100k_base`
--   or `o200k_base`
--
--   Example usage:
--
-- @
-- {-# LANGUAGE OverloadedStrings #-}
--
-- import "Tiktoken" (`o200k_base`, toTokens, toRanks)
--
-- main :: `IO` ()
-- main = do
--     -- `Just` [\"El\",\" perro\",\" come\",\" las\",\" man\",\"z\",\"anas\"]
--     `print` (`toTokens` `o200k_base` \"El perro come las manzanas\")
--
--     -- `Just` [4422,96439,3063,1996,873,89,14457]
--     `print` (`toRanks` `o200k_base` \"El perro come las manzanas\")
-- @
module Tiktoken
    ( -- * Encoding
      Encoding
    , tiktokenToEncoding
    , addSpecialTokens

      -- * Stock Encodings
    , r50k_base
    , p50k_base
    , p50k_edit
    , cl100k_base
    , o200k_base

      -- * Tokenization
    , toTokens
    , toRanks
    , toTokensAndRanks

      -- * Detokenization
    , fromTokens
    , fromRanks
    ) where

import Control.Applicative ((<|>))
import Control.DeepSeq (NFData)
import Control.Monad.ST (ST)
import Control.Monad.Trans.Class (lift)
import Data.ByteString (ByteString)
import Data.Function (on)
import Data.HashMap.Strict (HashMap)
import Data.IntMap (IntMap, Key)
import Data.List.NonEmpty (NonEmpty(..))
import Data.Map (Map)
import Data.Text (Text)
import Data.Vector (MVector, Vector, (!?))
import Data.Void (Void)
import Data.Word (Word8)
import GHC.Generics (Generic)
import System.FilePath ((</>))
import Text.Megaparsec (ParseErrorBundle, ParsecT)
import Text.RawString.QQ (r)

import qualified Control.Exception as Exception
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Base64 as Base64.Encoding
import qualified Data.ByteString.Char8 as Char8
import qualified Data.HashMap.Strict as HashMap
import qualified Data.IntMap.Strict as IntMap
import qualified Data.List as List
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map as Map
import qualified Data.Ord as Ord
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text.Encoding
import qualified Data.Text.IO as Text.IO
import qualified Data.Vector as Vector
import qualified Data.Vector.Mutable as Vector.Mutable
import qualified Paths_tiktoken as Paths
import qualified System.IO.Unsafe as Unsafe
import qualified Text.Megaparsec as Megaparsec
import qualified Text.Megaparsec.Char as Megaparsec.Char
import qualified Text.Regex.PCRE.Light as Regex

{-| This is an efficient internal representation of an encoding like
    @cl100k_base@, @p50k_edit@, or @o200k_base@
-}
data Encoding = Encoding
    { encode :: HashMap ByteString Int
    , decode :: Vector ByteString
    , specialTokens :: Map ByteString Int
    , regex :: ByteString
    } deriving stock (Generic)
      deriving anyclass (NFData)

parseToken :: ParsecT Void Text m ByteString
parseToken = do
    base64Text <- Megaparsec.takeWhileP (Just "Base64 character") (/= ' ')

    let base64Bytes = Text.Encoding.encodeUtf8 base64Text

    token <- case Base64.Encoding.decodeBase64Untyped base64Bytes of
        Left text -> fail (Text.unpack text)
        Right token -> return token

    -- We don't bother parsing the token ID because the tokens are always stored
    -- in sequential order by token ID.  We could *not* assume this but this
    -- would not only make the parsing slower but it would also require using
    -- a `HashMap` instead of a `Vector` to handle potential gaps in the token
    -- ID sequence.  It's much more efficient to make this simplifying
    -- assumption.

    _ <- Megaparsec.takeWhileP (Just "Base64 character") (/= '\n')

    _ <- Megaparsec.Char.char '\n'

    return token

parseDecode :: ParsecT Void Text (ST s) (MVector s ByteString)
parseDecode = do
    -- 100,000 is the size of the largest commonly-used encoding at the time of
    -- this writing (`cl100k_base`) and it's not that expensive to pre-allocate
    -- a `Vector` that big, so let's go wild and start with a large allocation.
    let initialSize = 100_000

    initialVector <- lift (Vector.Mutable.new initialSize)

    let loop index vector
            | index < size = do
                let success = do
                        token <- parseToken

                        lift (Vector.Mutable.write vector index token)

                        loop (index + 1) vector

                let failure = do
                        return (Vector.Mutable.take index vector)

                success <|> failure
                
            | otherwise = do
                largerVector <- lift (Vector.Mutable.grow vector size)

                loop index largerVector
          where
            size = Vector.Mutable.length vector

    loop 0 initialVector

-- | Create an `Encoding` from regular expression and an ordered set of tokens
tokensToEncoding
    :: ByteString
    -- ^ Regular expression used for coarse-grained splitting of the input
    -> Vector ByteString
    -- ^ The tokens in sequential order of their token IDs
    -> Encoding
tokensToEncoding regex decode = Encoding{..}
  where
    encode = HashMap.fromList (Vector.toList (Vector.imap adapt decode))
      where
        adapt index token = (token, index)

    specialTokens = mempty

-- | Parse an encoding from the @.tiktoken@ file format
tiktokenToEncoding
    :: ByteString
    -- ^ Regular expression used for coarse-grained splitting of the input
    -> Text
    -- ^ The contents of the @.tiktoken@ file
    -> Either (ParseErrorBundle Text Void) Encoding
tiktokenToEncoding regex text =
    fmap (tokensToEncoding regex)
        (Vector.createT (Megaparsec.runParserT parseDecode "" text))

-- | Add special tokens to a base `Encoding`
addSpecialTokens :: Map ByteString Int -> Encoding -> Encoding
addSpecialTokens tokens Encoding{ specialTokens = oldSpecialTokens, .. } =
    Encoding{ specialTokens = Map.union tokens oldSpecialTokens, .. }

_ENDOFTEXT :: ByteString
_ENDOFTEXT = "<|endoftext|>"

_FIM_PREFIX :: ByteString
_FIM_PREFIX = "<|fim_prefix|>"

_FIM_MIDDLE :: ByteString
_FIM_MIDDLE = "<|fim_middle|>"

_FIM_SUFFIX :: ByteString
_FIM_SUFFIX = "<|fim_suffix|>"

_ENDOFPROMPT :: ByteString
_ENDOFPROMPT = "<|endofprompt|>"

loadEncoding :: FilePath -> ByteString -> Map ByteString Int -> IO Encoding
loadEncoding file regex specialTokens = do
    dataDirectory <- Paths.getDataDir

    text <- Text.IO.readFile (dataDirectory </> file)

    encoding <- case tiktokenToEncoding regex text of
        Left exception -> Exception.throwIO exception
        Right encoding -> return encoding

    return (addSpecialTokens specialTokens encoding)

-- | @r50k_base@ `Encoding`
r50k_base :: Encoding
r50k_base =
    Unsafe.unsafePerformIO
        (loadEncoding "r50k_base.tiktoken" regex [ (_ENDOFTEXT, 50256) ])
  where
    regex =
        [r|'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+|]
{-# NOINLINE r50k_base #-}

-- | @p50k_base@ `Encoding`
p50k_base :: Encoding
p50k_base =
    Unsafe.unsafePerformIO
        (loadEncoding "p50k_base.tiktoken" regex [ (_ENDOFTEXT, 50256) ])
  where
    regex =
        [r|'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+|]
{-# NOINLINE p50k_base #-}

-- | @p50k_edit@ `Encoding`
p50k_edit :: Encoding
p50k_edit =
    Unsafe.unsafePerformIO
        (loadEncoding "p50k_base.tiktoken"
            regex
            [ (_ENDOFTEXT , 50256)
            , (_FIM_PREFIX, 50281)
            , (_FIM_MIDDLE, 50282)
            , (_FIM_SUFFIX, 50283)
            ] 
        )
  where
    regex =
        [r|'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+|]
{-# NOINLINE p50k_edit #-}

-- | @cl100k_base@ `Encoding`
cl100k_base :: Encoding
cl100k_base =
    Unsafe.unsafePerformIO
        (loadEncoding "cl100k_base.tiktoken"
            regex
            [ (_ENDOFTEXT  , 100257)
            , (_FIM_PREFIX , 100258)
            , (_FIM_MIDDLE , 100259)
            , (_FIM_SUFFIX , 100260)
            , (_ENDOFPROMPT, 100276)
            ]
        )
  where
    regex =
        [r|'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+|]
{-# NOINLINE cl100k_base #-}

-- | @o200k_base@ `Encoding`
o200k_base :: Encoding
o200k_base =
    Unsafe.unsafePerformIO
        (loadEncoding "o200k_base.tiktoken"
            regex
            [ (_ENDOFTEXT  , 199999)
            , (_ENDOFPROMPT, 200018)
            ]
        )
  where
    regex =
        Char8.intercalate "|"
            [ [r|[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?|]
            , [r|[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?|]
            , [r|\p{N}{1,3}|]
            , [r| ?[^\s\p{L}\p{N}]+[\r\n/]*|]
            , [r|\s*[\r\n]+|]
            , [r|\s+(?!\S)|]
            , [r|\s+|]
            ]
{-# NOINLINE o200k_base #-}

minimumBy :: (a -> a -> Ordering) -> IntMap a -> Maybe (Int, a)
minimumBy comparison intMap
    | IntMap.null intMap =
        Nothing
    | otherwise =
        Just (List.minimumBy (comparison `on` snd) (IntMap.toList intMap))

drop1 :: [a] -> [a]
drop1 (_ : xs) = xs
drop1      []  = []

{-| This is basically the same thing as `Maybe Int` except with an `Ord`
    instance that treats `Ranked` values as less than `Unranked` values
-}
data Ranked = Ranked Int | Unranked
    deriving (Eq, Ord)

data Chunk = Chunk
    { rank  :: Int
      -- ^ Rank of this chunk
    , rank2 :: Ranked
      -- ^ Rank of this chunk combined with the next chunk
    }

{-| This corresponds to the `_byte_pair_merge` function in the upstream `tiktoken`
    package:

    https://github.com/openai/tiktoken/blob/c0ba74c238d18b4824c25f3c27fc8698055b9a76/src/lib.rs#L18-L74

    The intermediate data structure is an `IntMap` instead of a `Vector` but other
    than that the algorithm is essentially identical.
-}
bytePairEncode
    :: HashMap ByteString Int -> ByteString -> Maybe [(Int, ByteString)]
bytePairEncode hashMap bytes
    | Just rank <- HashMap.lookup bytes hashMap =
        Just [ (rank, bytes) ]
    | ByteString.null bytes =
        pure []
    | otherwise = do
        -- In practice this should always return a `Just` because all of
        -- OpenAI's encodings are defined all bytes, but in theory the user
        -- could create an `Encoding` that doesn't satisfy that invariant, so
        -- we still need to handle that case.
        let lookupByte :: Word8 -> Maybe Int
            lookupByte word8 = HashMap.lookup (ByteString.singleton word8) hashMap

        let toChunk w0 w1 = do
                rank <- lookupByte w0

                let rank2 = lookupSlice (ByteString.pack [ w0, w1 ])

                pure Chunk{ rank, rank2 }

        initChunks <- do
            sequence (ByteString.zipWith toChunk bytes (ByteString.tail bytes))

        lastChunk <- do
            rank <- lookupByte (ByteString.last bytes)

            pure Chunk{ rank, rank2 = Unranked }

        {- Unlike the upstream `tiktoken` we do not use `Vector (Key, Ranked)`
           as our intermediate datastructure, but rather something more like
           `IntMap Ranked` (technically `IntMap Chunk`, which is just a tiny
           optimization).

           This makes it cheaper to delete keys without having to rebuild a
           `Vector` each time, but at the expense of neighbor lookups (e.g.
           `lookupLT` / `lookupGT`) being more expensive.
        -}
        let initialMap :: IntMap Chunk
            initialMap =
                IntMap.fromList (zip [0 ..] (initChunks <> [ lastChunk ]))

        let keyValues = IntMap.toAscList (loop initialMap)

        pure do
            let adapt (index, Chunk{ rank }) nextIndex =
                    (rank, slice index nextIndex)

            zipWith adapt keyValues (drop1 (map fst keyValues) <> [ size ])
  where
    size :: Int
    size = ByteString.length bytes

    lookupSlice :: ByteString -> Ranked
    lookupSlice b = case HashMap.lookup b hashMap of
        Nothing  -> Unranked
        Just int -> Ranked int

    slice :: Int -> Int -> ByteString
    slice begin end = ByteString.take (end - begin) (ByteString.drop begin bytes)

    loop :: IntMap Chunk -> IntMap Chunk
    loop chunks0 = case minimumBy (Ord.comparing rank2) chunks0 of
        Just (index, Chunk{ rank2 = Ranked ranked }) -> loop chunks3
          where
            chunks1 = rerank index ranked chunks0

            chunks2 = case IntMap.lookupLT index chunks1 of
                Just (prevIndex, Chunk{ rank = prevRanked }) ->
                    rerank prevIndex prevRanked chunks1
                _ ->
                    chunks1

            chunks3 = case IntMap.lookupGT index chunks2 of
                -- In theory we should never hit the `Nothing` case here because
                -- the `rank2` field can only be `Ranked` if there is a `Chunk`
                -- following this one.
                Nothing ->
                    error "Tiktoken.bytePairEncode: Internal error - a ranked byte pair is missing the second byte in the pair"
                Just (nextIndex, _) -> IntMap.delete nextIndex chunks2

        _ ->
            chunks0

    rerank :: Key -> Int -> IntMap Chunk -> IntMap Chunk
    rerank index0 rank chunks = IntMap.insert index0 newChunk chunks
      where
        maybeIndex3 = do
            (index1, _) <- IntMap.lookupGT index0 chunks

            (index2, _) <- IntMap.lookupGT index1 chunks

            pure case IntMap.lookupGT index2 chunks of
                Just (index3, _) -> index3
                Nothing          -> size

        rank2 = case maybeIndex3 of
            Nothing     -> Unranked
            Just index3 -> lookupSlice (slice index0 index3)

        newChunk = Chunk{ rank, rank2 }

{-| Split a `ByteString` into smaller `ByteString`s, each of which are
    successive longest possible matches to the provided regular expression
-}
splitUsingRegex
    :: ByteString
    -- ^ Regex to match
    -> ByteString
    -- ^ Bytes to split into chunks
    -> Maybe [ByteString]
splitUsingRegex pattern = loop Prelude.id
  where
    loop diff bytes
        | ByteString.null bytes =
            Just (diff [])
        | otherwise =
            case Regex.match regex bytes [ Regex.exec_no_utf8_check ] of
                Just (prefix : _) ->
                    let suffix = ByteString.drop (ByteString.length prefix) bytes
                    in  loop (diff . (prefix :)) suffix
                _ -> Nothing

    regex = Regex.compile pattern [ Regex.utf8 ]

{-| Divide up the input into coarse-grained chunks based on the provided splitting
    regular expression before doing the final byte pair encoding
-}
bytePairEncodeWithSplitting :: Encoding -> ByteString -> Maybe [(Int, ByteString)]
bytePairEncodeWithSplitting Encoding{..} bytes = do
    chunks <- splitUsingRegex regex bytes

    tokenss <- traverse (bytePairEncode encode) chunks

    pure (concat tokenss)

{-| Split a `ByteString` into smaller `ByteString`s separated by the given
    separator
-}
splitOnSeparator
    :: ByteString
    -- ^ Separator
    -> ByteString
    -- ^ `ByteString` to separate
    -> NonEmpty ByteString
splitOnSeparator separator initialBytes = initialPrefix :| loop initialSuffix
  where
    split = ByteString.breakSubstring separator

    (initialPrefix, initialSuffix) = split initialBytes

    loop bytes
        | ByteString.null bytes = []
        | otherwise             = prefix : loop suffix
      where
        rest = ByteString.drop (ByteString.length separator) bytes

        (prefix, suffix) = split rest

{-| Use an `Encoding` to tokenize a `ByteString` into smaller `ByteString`s and
    their associated ranks

    This only fails if you provide an `Encoding` that cannot rank all possible
    1-byte sequences
-}
toTokensAndRanks :: Encoding -> ByteString -> Maybe [(Int, ByteString)]
toTokensAndRanks encoding@Encoding{..} initialBytes =
    foldr cons nil (Map.toList specialTokens) initialBytes
  where
    cons (token, rank) tokenizer bytes = do
        fmap joinSegments (traverse tokenizer (splitOnSeparator token bytes))
      where
        joinSegments =
              concat
            . NonEmpty.toList
            . NonEmpty.intersperse [ (rank, token) ]

    nil bytes = bytePairEncodeWithSplitting encoding bytes

{-| Use an `Encoding` to tokenize a `ByteString` into smaller `ByteString`s

    This only fails if you provide an `Encoding` that cannot rank all possible
    1-byte sequences
-}
toTokens :: Encoding -> ByteString -> Maybe [ByteString]
toTokens = fmap (fmap (fmap (fmap snd))) toTokensAndRanks

{-| Use an `Encoding` to tokenize a `ByteString` into ranks

    This only fails if you provide an `Encoding` that cannot rank all possible
    1-byte sequences
-}
toRanks :: Encoding -> ByteString -> Maybe [Int]
toRanks = fmap (fmap (fmap (fmap fst))) toTokensAndRanks

{-| Combine a sequence of `ByteString` tokens back into a `ByteString`

    This is just a synonym for @"Data.ByteString".`ByteString.concat`@ (no
    `Encoding` necessary), provided solely for consistency/convenience.
-}
fromTokens :: [ByteString] -> ByteString
fromTokens = ByteString.concat

{-| Convert a sequence of ranks back into a `ByteString`

    This will fail if you supply any ranks which are not recognized by the
    `Encoding`.
-}
fromRanks :: Encoding -> [Int] -> Maybe ByteString
fromRanks Encoding{..} vector = fmap fromTokens (traverse (decode !?) vector)