diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for bytes-metrics
+
+## 0.1.0.0 -- 2024-01-30
+
+* First version.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2020, Eric Demko
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Andrew Martin nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,3 @@
+import Distribution.Simple
+
+main = defaultMain
diff --git a/bench/Main.hs b/bench/Main.hs
new file mode 100644
--- /dev/null
+++ b/bench/Main.hs
@@ -0,0 +1,28 @@
+import Data.Bytes (Bytes)
+import Data.Bytes.Metrics (levenshteinWithTolerance)
+import Data.List (unfoldr)
+import Gauge.Main (bench, bgroup, defaultMain, whnf)
+import System.Random (mkStdGen, uniformR)
+
+import qualified Data.Bytes.Text.Ascii as Ascii
+
+main :: IO ()
+main =
+  defaultMain
+    [ bgroup
+        "80-chars"
+        [ bench "distance-0" (whnf (uncurry $ levenshteinWithTolerance 0) strings)
+        , bench "distance-1" (whnf (uncurry $ levenshteinWithTolerance 1) strings)
+        , bench "distance-2" (whnf (uncurry $ levenshteinWithTolerance 2) strings)
+        , bench "distance-3" (whnf (uncurry $ levenshteinWithTolerance 3) strings)
+        , bench "distance-4" (whnf (uncurry $ levenshteinWithTolerance 4) strings)
+        ]
+    ]
+
+prefix :: String
+{-# NOINLINE prefix #-}
+prefix = take 80 (unfoldr (Just . uniformR (' ', '~')) (mkStdGen 62861853071))
+
+strings :: (Bytes, Bytes)
+{-# NOINLINE strings #-}
+strings = (Ascii.fromString $ prefix ++ "a", Ascii.fromString $ prefix ++ "b")
diff --git a/bytes-metrics.cabal b/bytes-metrics.cabal
new file mode 100644
--- /dev/null
+++ b/bytes-metrics.cabal
@@ -0,0 +1,64 @@
+cabal-version:   3.0
+name:            bytes-metrics
+version:         0.1.0.0
+synopsis:        Calculate string metrics on Bytes efficiently
+description:
+  Calculate string metrics on Bytes efficiently. This library currently only
+  calculates Levenshtein distance, but other metrics may be added in the future.
+
+homepage:        https://github.com/byteverse/bytes-metrics
+bug-reports:     https://github.com/byteverse/bytes-metrics/issues
+license:         BSD-3-Clause
+license-file:    LICENSE
+author:          Eric Demko
+maintainer:      amartin@layer3com.com
+copyright:       2020 Eric Demko
+category:        Data
+build-type:      Simple
+extra-doc-files: CHANGELOG.md
+
+library
+  exposed-modules:  Data.Bytes.Metrics
+  build-depends:
+    , base        >=4.14  && <5
+    , byteslice   >=0.2.1 && <0.3
+    , contiguous  >=0.6.1 && <0.7
+    , primitive   >=0.7   && <0.10
+
+  hs-source-dirs:   src
+  default-language: Haskell2010
+  ghc-options:      -O2
+
+test-suite test
+  default-language: Haskell2010
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   test
+  main-is:          Main.hs
+  ghc-options:      -Wall -O2
+  build-depends:
+    , base                     >=4.12.0.0 && <5
+    , bytes-metrics
+    , byteslice
+    , primitive                >=0.7      && <0.10
+    , quickcheck-classes-base  >=0.6
+    , tasty                    >=1.2.3
+    , tasty-hunit              >=0.10.0.2
+    , tasty-quickcheck         >=0.10
+
+benchmark bench
+  type:             exitcode-stdio-1.0
+  build-depends:
+    , base
+    , bytes-metrics
+    , byteslice
+    , gauge
+    , random         >=1.2
+
+  ghc-options:      -Wall -O2
+  default-language: Haskell2010
+  hs-source-dirs:   bench
+  main-is:          Main.hs
+
+source-repository head
+  type:     git
+  location: git://github.com/byteverse/bytes-metrics.git
diff --git a/src/Data/Bytes/Metrics.hs b/src/Data/Bytes/Metrics.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Bytes/Metrics.hs
@@ -0,0 +1,89 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Data.Bytes.Metrics
+  ( levenshteinWithTolerance
+  , isWithinLevenshtein
+  ) where
+
+import Control.Monad.ST (runST)
+import Data.Bytes (Bytes)
+
+import qualified Data.Bytes as Bytes
+import qualified Data.Primitive.Contiguous as Arr
+import qualified Data.Primitive.PrimArray as Prim
+
+{- | Determine if two 'Bytes' are within a given Levenshtein distance of each other (inclusive).
+Computes in O(t*min(n,m)) time and O(min(t,n,m)) space,
+where @n,m@ are lengths of the input strings and @t@ is the tolerance.
+-}
+isWithinLevenshtein :: Int -> Bytes -> Bytes -> Bool
+isWithinLevenshtein t a b = maybe False (<= t) $ levenshteinWithTolerance t a b
+
+{- | Determine Levenshtein distance between two strings, as long as their
+distance is within (inclusive) the given tolerance.
+Computes in O(t*min(n,m)) time and O(min(t,n,m)) space,
+where @n,m@ are lengths of the input strings and @t@ is the tolerance.
+-}
+levenshteinWithTolerance :: Int -> Bytes -> Bytes -> Maybe Int
+levenshteinWithTolerance !t !a !b
+  -- ensure that the first string (which will create columns) is longer
+  -- this minimizes the space needed for intermediate results
+  | t == 0 = if a == b then Just 0 else Nothing
+  | m > n = levenshteinWithWorker t b a
+  | otherwise = levenshteinWithWorker t a b
+ where
+  m = Bytes.length a
+  n = Bytes.length b
+
+-- Precondition: Length of A is less than or equal to length of B.
+levenshteinWithWorker :: Int -> Bytes -> Bytes -> Maybe Int
+levenshteinWithWorker !t !a !b
+  | t < deltaN = Nothing
+  | otherwise = runST $ do
+      -- during table creation, some column indices will be negative:
+      -- the contents of such oob cells must not impact the contents of in-bounds cells
+      -- using maxBound to initialize could provoke overflow on increment
+      -- using n+m will definitely be larger than any entry in the table, but likely small enough to avoid wrapping arithmetic
+      row :: Prim.MutablePrimArray s Int <- Arr.replicateMut rowLen (n + m)
+      let outerLoop !rowIx
+            | rowIx <= m = do
+                let innerLoop !bandIx
+                      | bandIx < rowLen = do
+                          let colIx = rowIx - p + bandIx
+                          let initCost = if rowIx == 0 && colIx == 0 then 0 else maxBound
+                          let !byteA = Bytes.unsafeIndex a (rowIx - 1)
+                          let !byteB = Bytes.unsafeIndex b (colIx - 1)
+                          !editCost <-
+                            if
+                              | not (1 <= colIx && colIx <= n) -> pure maxBound
+                              | byteA == byteB -> Arr.read row bandIx
+                              | otherwise -> (1 +) <$> Arr.read row bandIx
+                          !insCost <-
+                            if 0 <= bandIx - 1
+                              then (1 +) <$> Arr.read row (bandIx - 1)
+                              else pure maxBound
+                          !delCost <-
+                            if bandIx + 1 < rowLen
+                              then (1 +) <$> Arr.read row (bandIx + 1)
+                              else pure maxBound
+                          let cost = min (min initCost editCost) (min insCost delCost)
+                          Arr.write row bandIx cost
+                          innerLoop (bandIx + 1)
+                      | otherwise = pure ()
+                innerLoop 0
+                outerLoop (rowIx + 1)
+            | otherwise = pure ()
+      outerLoop 0
+      d <- Arr.read row (deltaN + p)
+      pure $ Just d
+ where
+  m = Bytes.length a
+  n = Bytes.length b
+  deltaN = n - m
+  -- FIXME what a gross name, what even is p really supposed to be? a one-sided external tolerance for the diagonal band?
+  p = (t - deltaN) `quot` 2
+  -- \| the other way to think of this length is `t - deltaN + (1 - t `mod` 2)`
+  -- the floor operation to compute `p` is what gives it that awful last term, and why I'm sticking with the paper's presentation
+  rowLen = 1 + deltaN + 2 * p
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,57 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Main (main) where
+
+import Data.Bytes (Bytes)
+import Data.Bytes.Metrics (levenshteinWithTolerance)
+import Data.Word (Word8)
+
+import Test.Tasty (TestTree, defaultMain, testGroup)
+import Test.Tasty.QuickCheck (Arbitrary (..), discard, testProperty, (===))
+
+import qualified Data.Bytes as Bytes
+import qualified Data.Bytes.Text.Ascii as Ascii
+import qualified Data.Primitive as Prim
+import qualified Test.Tasty.QuickCheck as TQC
+
+main :: IO ()
+main = defaultMain tests
+
+tests :: TestTree
+tests =
+  testGroup
+    "Tests"
+    [ testGroup
+        "sanity properties"
+        [ testProperty "commutative" $ \a b ->
+            let d = levenshteinWithTolerance 100 a b
+                d' = levenshteinWithTolerance 100 b a
+             in d === d'
+        , testProperty "non-negative" $ \a b ->
+            case levenshteinWithTolerance 100 a b of
+              Nothing -> discard
+              Just d -> d >= 0
+        , testProperty "zero distance to self" $ \t a ->
+            levenshteinWithTolerance (abs t) a a === Just 0
+        , testProperty "distance to empty is length" $ \a ->
+            let d = Bytes.length a
+             in levenshteinWithTolerance d a Bytes.empty === Just d
+        ]
+    , testGroup
+        "golden tests"
+        [ testProperty "hellofworld" $
+            let a = Ascii.fromString "hello world"
+                b = Ascii.fromString "hellofworld"
+             in levenshteinWithTolerance 10 a b == Just 1
+        , testProperty "xyzzy" $
+            let a = Ascii.fromString "xyzzy"
+                b = Ascii.fromString "syzygy"
+             in levenshteinWithTolerance 10 a b == Just 3
+        ]
+    ]
+
+instance Arbitrary Bytes where
+  arbitrary = do
+    bs :: [Word8] <- TQC.arbitrary
+    pure $ Bytes.fromByteArray $ Prim.byteArrayFromList bs
