diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,13 @@
 # Changelog
 
+- 0.3.1 (2026-10-10)
+  * The portable implementation now lives in an internal
+    'Data.ByteString.Base16.Pure' module, and
+    'Data.ByteString.Base16.Arm' is no longer exposed. Both are hidden
+    internal modules; the public API is unchanged.
+  * Expands the test suite to exercise both the NEON and portable
+    implementations, including on invalid inputs.
+
 - 0.3.0 (2026-05-16)
   * Features order-of-magnitude performance improvements in both
     encoding and decoding, especially on ARM platforms where NEON
diff --git a/bench/Main.hs b/bench/Main.hs
--- a/bench/Main.hs
+++ b/bench/Main.hs
@@ -15,6 +15,10 @@
 main = defaultMain [
     minimal_encode
   , minimal_decode
+  , encode
+  , decode
+  , encode_various
+  , decode_various
   ]
 
 minimal_encode :: Benchmark
@@ -47,7 +51,7 @@
   ]
 
 decode_various :: Benchmark
-decode_various = bgroup "base16" [
+decode_various = bgroup "decode (input size)" [
     bench "1024B input" $ nf B16.decode (B16.encode (BS.replicate 512 0x00))
   , bench "1026B input" $ nf B16.decode (B16.encode (BS.replicate 513 0x00))
   , bench "1028B input" $ nf B16.decode (B16.encode (BS.replicate 514 0x00))
@@ -60,7 +64,7 @@
   ]
 
 encode_various :: Benchmark
-encode_various = bgroup "base16" [
+encode_various = bgroup "encode (input size)" [
     bench "1024B input" $ nf B16.encode (BS.replicate 1024 0x00)
   , bench "1023B input" $ nf B16.encode (BS.replicate 1023 0x00)
   , bench "1022B input" $ nf B16.encode (BS.replicate 1022 0x00)
diff --git a/bench/Weight.hs b/bench/Weight.hs
--- a/bench/Weight.hs
+++ b/bench/Weight.hs
@@ -23,4 +23,4 @@
 
   W.func "ppad-base16 (decode)" B16.decode hinp
   W.func "base16-bytestring (decode)" R0.decode hinp
-  W.func "base16 (decode)" R1.decodeBase16Untyped inp
+  W.func "base16 (decode)" R1.decodeBase16Untyped hinp
diff --git a/lib/Data/ByteString/Base16.hs b/lib/Data/ByteString/Base16.hs
--- a/lib/Data/ByteString/Base16.hs
+++ b/lib/Data/ByteString/Base16.hs
@@ -1,6 +1,4 @@
 {-# OPTIONS_HADDOCK prune #-}
-{-# LANGUAGE BangPatterns #-}
-{-# LANGUAGE OverloadedStrings #-}
 
 -- |
 -- Module: Data.ByteString.Base16
@@ -8,85 +6,16 @@
 -- License: MIT
 -- Maintainer: Jared Tobin <jared@ppad.tech>
 --
--- Pure base16 encoding and decoding of strict bytestrings.
+-- Base16 encoding and decoding of strict bytestrings.
 
 module Data.ByteString.Base16 (
     encode
   , decode
   ) where
 
-import qualified Data.Bits as B
-import Data.Bits ((.&.), (.|.))
 import qualified Data.ByteString as BS
 import qualified Data.ByteString.Base16.Arm as Arm
-import qualified Data.ByteString.Internal as BI
-import Data.Word (Word8, Word16)
-import Foreign.ForeignPtr (withForeignPtr)
-import Foreign.Ptr (Ptr, castPtr, plusPtr)
-import Foreign.Storable (peekElemOff, pokeElemOff)
-import System.IO.Unsafe (unsafeDupablePerformIO)
-
-fi :: (Num a, Integral b) => b -> a
-fi = fromIntegral
-{-# INLINE fi #-}
-
--- 512-byte table.  Bytes [2k] and [2k+1] are the two lowercase ASCII
--- hex characters representing the value k.  All-ASCII content means
--- the bytestring 'IsString' rule rewrites this to 'unsafePackAddress'
--- and the bytes live in static rodata.
-enc_tab :: BS.ByteString
-enc_tab =
-  "000102030405060708090a0b0c0d0e0f\
-  \101112131415161718191a1b1c1d1e1f\
-  \202122232425262728292a2b2c2d2e2f\
-  \303132333435363738393a3b3c3d3e3f\
-  \404142434445464748494a4b4c4d4e4f\
-  \505152535455565758595a5b5c5d5e5f\
-  \606162636465666768696a6b6c6d6e6f\
-  \707172737475767778797a7b7c7d7e7f\
-  \808182838485868788898a8b8c8d8e8f\
-  \909192939495969798999a9b9c9d9e9f\
-  \a0a1a2a3a4a5a6a7a8a9aaabacadaeaf\
-  \b0b1b2b3b4b5b6b7b8b9babbbcbdbebf\
-  \c0c1c2c3c4c5c6c7c8c9cacbcccdcecf\
-  \d0d1d2d3d4d5d6d7d8d9dadbdcdddedf\
-  \e0e1e2e3e4e5e6e7e8e9eaebecedeeef\
-  \f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"
-{-# NOINLINE enc_tab #-}
-
--- 256-byte table.  Index by an ASCII byte to obtain its nibble; valid
--- hex chars ('0'..'9', 'a'..'f', 'A'..'F') map to 0x10..0x1f, every
--- other byte maps to 0x20.
---
--- The encoding is chosen so the literal is strictly ASCII and contains
--- no embedded NUL, which is what the bytestring 'IsString' rule needs
--- to rewrite it into 'unsafePackAddress' (cf. 'enc_tab') — the bytes
--- end up in static rodata, with no CAF allocation.
---
--- The 0x20 sentinel is distinguished by bit 5; no value 0x10..0x1f
--- carries that bit, so 'decode' OR-folds every lookup into an
--- accumulator and tests 'acc .&. 0x20 == 0' once at the end.  The
--- output byte is '(n0 `shiftL` 4) .|. (n1 .&. 0x0f)': in 'Word8' the
--- shift naturally drops bit 4, and the mask isolates the low nibble.
-dec_tab :: BS.ByteString
-dec_tab =
-  "\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x20\x20\x20\x20\x20\x20\
-  \\x20\x1a\x1b\x1c\x1d\x1e\x1f\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x1a\x1b\x1c\x1d\x1e\x1f\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
-  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20"
-{-# NOINLINE dec_tab #-}
+import qualified Data.ByteString.Base16.Pure as Pure
 
 -- | Encode a base256 'ByteString' as base16.
 --
@@ -98,7 +27,7 @@
 encode :: BS.ByteString -> BS.ByteString
 encode bs
   | Arm.base16_arm_available = Arm.encode bs
-  | otherwise = encode_scalar bs
+  | otherwise = Pure.encode bs
 {-# INLINABLE encode #-}
 
 -- | Decode a base16 'ByteString' to base256.
@@ -114,58 +43,5 @@
 decode :: BS.ByteString -> Maybe BS.ByteString
 decode bs
   | Arm.base16_arm_available = Arm.decode bs
-  | otherwise = decode_scalar bs
+  | otherwise = Pure.decode bs
 {-# INLINABLE decode #-}
-
-encode_scalar :: BS.ByteString -> BS.ByteString
-encode_scalar (BI.PS sfp soff l) =
-  case enc_tab of
-    BI.PS tfp toff _ ->
-      BI.unsafeCreate (l `B.shiftL` 1) $ \dst ->
-        withForeignPtr sfp $ \sp0 ->
-        withForeignPtr tfp $ \tp0 -> do
-          -- read 'enc_tab' and write 'dst' as 'Word16' pairs.  The
-          -- two-byte block at 'enc_tab[2*b]' and the two-byte block
-          -- at 'dst[2*i]' share the same byte layout in memory, so
-          -- this is endianness-safe: we never inspect the numerical
-          -- value of the 'Word16', we just shuffle 16 bits between
-          -- two locations.
-          let !sp = sp0 `plusPtr` soff :: Ptr Word8
-              !tp = tp0 `plusPtr` toff :: Ptr Word16
-              !dp = castPtr dst        :: Ptr Word16
-              loop !i
-                | i == l    = pure ()
-                | otherwise = do
-                    b <- peekElemOff sp i
-                    w <- peekElemOff tp (fi b)
-                    pokeElemOff dp i (w :: Word16)
-                    loop (i + 1)
-          loop 0
-
-decode_scalar :: BS.ByteString -> Maybe BS.ByteString
-decode_scalar (BI.PS sfp soff l)
-  | B.testBit l 0 = Nothing
-  | otherwise = case dec_tab of
-      BI.PS tfp toff _ -> unsafeDupablePerformIO $ do
-        let !n = l `B.shiftR` 1
-        fp <- BI.mallocByteString n
-        ok <- withForeignPtr fp  $ \dst ->
-              withForeignPtr sfp $ \sp0 ->
-              withForeignPtr tfp $ \tp0 -> do
-                let !sp = sp0 `plusPtr` soff :: Ptr Word8
-                    !tp = tp0 `plusPtr` toff :: Ptr Word8
-                    loop !i !acc
-                      | i == n    =
-                          pure $! acc .&. 0x20 == 0
-                      | otherwise = do
-                          let !o = i `B.shiftL` 1
-                          c0 <- peekElemOff sp  o
-                          c1 <- peekElemOff sp (o + 1)
-                          n0 <- peekElemOff tp (fi c0)
-                          n1 <- peekElemOff tp (fi c1)
-                          let !b = (n0 `B.shiftL` 4)
-                               .|. (n1 .&. 0x0f)
-                          pokeElemOff dst i b
-                          loop (i + 1) (acc .|. n0 .|. n1)
-                loop 0 0
-        pure $! if ok then Just (BI.PS fp 0 n) else Nothing
diff --git a/lib/Data/ByteString/Base16/Pure.hs b/lib/Data/ByteString/Base16/Pure.hs
new file mode 100644
--- /dev/null
+++ b/lib/Data/ByteString/Base16/Pure.hs
@@ -0,0 +1,144 @@
+{-# OPTIONS_HADDOCK hide #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module: Data.ByteString.Base16.Pure
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Pure Haskell base16 encoding and decoding of strict bytestrings.
+
+module Data.ByteString.Base16.Pure (
+    encode
+  , decode
+  ) where
+
+import qualified Data.Bits as B
+import Data.Bits ((.&.), (.|.))
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Internal as BI
+import Data.Word (Word8, Word16)
+import Foreign.ForeignPtr (withForeignPtr)
+import Foreign.Ptr (Ptr, castPtr, plusPtr)
+import Foreign.Storable (peekElemOff, pokeElemOff)
+import System.IO.Unsafe (unsafeDupablePerformIO)
+
+fi :: (Num a, Integral b) => b -> a
+fi = fromIntegral
+{-# INLINE fi #-}
+
+-- 512-byte table.  Bytes [2k] and [2k+1] are the two lowercase ASCII
+-- hex characters representing the value k.  All-ASCII content means
+-- the bytestring 'IsString' rule rewrites this to 'unsafePackAddress'
+-- and the bytes live in static rodata.
+enc_tab :: BS.ByteString
+enc_tab =
+  "000102030405060708090a0b0c0d0e0f\
+  \101112131415161718191a1b1c1d1e1f\
+  \202122232425262728292a2b2c2d2e2f\
+  \303132333435363738393a3b3c3d3e3f\
+  \404142434445464748494a4b4c4d4e4f\
+  \505152535455565758595a5b5c5d5e5f\
+  \606162636465666768696a6b6c6d6e6f\
+  \707172737475767778797a7b7c7d7e7f\
+  \808182838485868788898a8b8c8d8e8f\
+  \909192939495969798999a9b9c9d9e9f\
+  \a0a1a2a3a4a5a6a7a8a9aaabacadaeaf\
+  \b0b1b2b3b4b5b6b7b8b9babbbcbdbebf\
+  \c0c1c2c3c4c5c6c7c8c9cacbcccdcecf\
+  \d0d1d2d3d4d5d6d7d8d9dadbdcdddedf\
+  \e0e1e2e3e4e5e6e7e8e9eaebecedeeef\
+  \f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"
+{-# NOINLINE enc_tab #-}
+
+-- 256-byte table.  Index by an ASCII byte to obtain its nibble; valid
+-- hex chars ('0'..'9', 'a'..'f', 'A'..'F') map to 0x10..0x1f, every
+-- other byte maps to 0x20.
+--
+-- The encoding is chosen so the literal is strictly ASCII and contains
+-- no embedded NUL, which is what the bytestring 'IsString' rule needs
+-- to rewrite it into 'unsafePackAddress' (cf. 'enc_tab') — the bytes
+-- end up in static rodata, with no CAF allocation.
+--
+-- The 0x20 sentinel is distinguished by bit 5; no value 0x10..0x1f
+-- carries that bit, so 'decode' OR-folds every lookup into an
+-- accumulator and tests 'acc .&. 0x20 == 0' once at the end.  The
+-- output byte is '(n0 `shiftL` 4) .|. (n1 .&. 0x0f)': in 'Word8' the
+-- shift naturally drops bit 4, and the mask isolates the low nibble.
+dec_tab :: BS.ByteString
+dec_tab =
+  "\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x20\x20\x20\x20\x20\x20\
+  \\x20\x1a\x1b\x1c\x1d\x1e\x1f\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x1a\x1b\x1c\x1d\x1e\x1f\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\
+  \\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20"
+{-# NOINLINE dec_tab #-}
+
+-- | Encode a base256 'ByteString' as base16.
+encode :: BS.ByteString -> BS.ByteString
+encode (BI.PS sfp soff l) =
+  case enc_tab of
+    BI.PS tfp toff _ ->
+      BI.unsafeCreate (l `B.shiftL` 1) $ \dst ->
+        withForeignPtr sfp $ \sp0 ->
+        withForeignPtr tfp $ \tp0 -> do
+          -- read 'enc_tab' and write 'dst' as 'Word16' pairs.  The
+          -- two-byte block at 'enc_tab[2*b]' and the two-byte block
+          -- at 'dst[2*i]' share the same byte layout in memory, so
+          -- this is endianness-safe: we never inspect the numerical
+          -- value of the 'Word16', we just shuffle 16 bits between
+          -- two locations.
+          let !sp = sp0 `plusPtr` soff :: Ptr Word8
+              !tp = tp0 `plusPtr` toff :: Ptr Word16
+              !dp = castPtr dst        :: Ptr Word16
+              loop !i
+                | i == l    = pure ()
+                | otherwise = do
+                    b <- peekElemOff sp i
+                    w <- peekElemOff tp (fi b)
+                    pokeElemOff dp i (w :: Word16)
+                    loop (i + 1)
+          loop 0
+
+-- | Decode a base16 'ByteString' to base256.  Invalid inputs
+--   (including odd-length inputs) will produce 'Nothing'.
+decode :: BS.ByteString -> Maybe BS.ByteString
+decode (BI.PS sfp soff l)
+  | B.testBit l 0 = Nothing
+  | otherwise = case dec_tab of
+      BI.PS tfp toff _ -> unsafeDupablePerformIO $ do
+        let !n = l `B.shiftR` 1
+        fp <- BI.mallocByteString n
+        ok <- withForeignPtr fp  $ \dst ->
+              withForeignPtr sfp $ \sp0 ->
+              withForeignPtr tfp $ \tp0 -> do
+                let !sp = sp0 `plusPtr` soff :: Ptr Word8
+                    !tp = tp0 `plusPtr` toff :: Ptr Word8
+                    loop !i !acc
+                      | i == n    =
+                          pure $! acc .&. 0x20 == 0
+                      | otherwise = do
+                          let !o = i `B.shiftL` 1
+                          c0 <- peekElemOff sp  o
+                          c1 <- peekElemOff sp (o + 1)
+                          n0 <- peekElemOff tp (fi c0)
+                          n1 <- peekElemOff tp (fi c1)
+                          let !b = (n0 `B.shiftL` 4)
+                               .|. (n1 .&. 0x0f)
+                          pokeElemOff dst i b
+                          loop (i + 1) (acc .|. n0 .|. n1)
+                loop 0 0
+        pure $! if ok then Just (BI.PS fp 0 n) else Nothing
diff --git a/ppad-base16.cabal b/ppad-base16.cabal
--- a/ppad-base16.cabal
+++ b/ppad-base16.cabal
@@ -1,7 +1,7 @@
 cabal-version:      3.0
 name:               ppad-base16
-version:            0.3.0
-synopsis:           Pure base16 encoding and decoding on bytestrings.
+version:            0.3.1
+synopsis:           Fast base16 encoding and decoding on bytestrings.
 license:            MIT
 license-file:       LICENSE
 author:             Jared Tobin
@@ -11,7 +11,7 @@
 tested-with:        GHC == { 9.10.3 }
 extra-doc-files:    CHANGELOG
 description:
-  Pure base16 (hexadecimal) encoding and decoding on bytestrings.
+  Fast base16 (hexadecimal) encoding and decoding on bytestrings.
 
 flag llvm
   description: Use GHC's LLVM backend.
@@ -36,6 +36,8 @@
     ghc-options: -fllvm -O2
   exposed-modules:
       Data.ByteString.Base16
+      Data.ByteString.Base16.Pure
+  other-modules:
       Data.ByteString.Base16.Arm
   build-depends:
       base >= 4.9 && < 5
@@ -99,7 +101,6 @@
     , base16
     , base16-bytestring
     , bytestring
-    , criterion
     , ppad-base16
     , weigh
 
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -4,72 +4,154 @@
 
 module Main where
 
+import Control.Monad (forM_)
 import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as B8
 import qualified "ppad-base16" Data.ByteString.Base16 as B16
+import qualified "ppad-base16" Data.ByteString.Base16.Pure as Pure
 import qualified "base16-bytestring" Data.ByteString.Base16 as R0
+import Data.Word (Word8)
 import Test.Tasty
 import qualified Test.Tasty.QuickCheck as Q
 import qualified Test.Tasty.HUnit as H
 
-newtype BS = BS BS.ByteString
-  deriving (Eq, Show)
+-- generators -----------------------------------------------------------------
 
+-- random bytes, as a slice at a random offset into a larger buffer, so
+-- that inputs with a non-zero 'ByteString' offset are exercised
 bytes :: Int -> Q.Gen BS.ByteString
 bytes k = do
   l <- Q.chooseInt (0, k)
-  v <- Q.vectorOf l Q.arbitrary
-  pure (BS.pack v)
+  o <- Q.chooseInt (0, 32)
+  v <- Q.vectorOf (o + l) Q.arbitrary
+  pure (BS.drop o (BS.pack v))
 
+newtype BS = BS BS.ByteString
+  deriving (Eq, Show)
+
 instance Q.Arbitrary BS where
   arbitrary = do
     b <- bytes 1024
     pure (BS b)
 
-decode_inverts_encode :: BS -> Bool
-decode_inverts_encode (BS bs) = case B16.decode (B16.encode bs) of
-  Nothing -> False
-  Just b  -> b == bs
+hex_chars :: BS.ByteString
+hex_chars = "0123456789abcdefABCDEF"
 
+-- mostly-hex strings of arbitrary (including odd) length: each byte is
+-- a hex char with high probability, otherwise an arbitrary byte
+newtype Hexish = Hexish BS.ByteString
+  deriving (Eq, Show)
+
+instance Q.Arbitrary Hexish where
+  arbitrary = do
+    l <- Q.chooseInt (0, 256)
+    o <- Q.chooseInt (0, 32)
+    junk <- Q.elements [0, 1, 50 :: Int]
+    v <- Q.vectorOf (o + l) $ do
+      p <- Q.chooseInt (1, 100)
+      if   p <= junk
+      then Q.arbitrary
+      else Q.elements (BS.unpack hex_chars)
+    pure (Hexish (BS.drop o (BS.pack v)))
+
+-- decoders under test --------------------------------------------------------
+
+decoders :: [(String, BS.ByteString -> Maybe BS.ByteString)]
+decoders = [("public", B16.decode), ("pure", Pure.decode)]
+
+ref_decode :: BS.ByteString -> Maybe BS.ByteString
+ref_decode bs = case R0.decode bs of
+  Left _  -> Nothing
+  Right d -> Just d
+
+is_hex :: Word8 -> Bool
+is_hex c = BS.elem c hex_chars
+
+-- properties -----------------------------------------------------------------
+
 encode_matches_reference :: BS -> Bool
 encode_matches_reference (BS bs) =
-  let us = B16.encode bs
-      r0 = R0.encode bs
-  in  us == r0
+  let r0 = R0.encode bs
+  in  B16.encode bs == r0 && Pure.encode bs == r0
 
-decode_matches_reference :: BS -> Bool
-decode_matches_reference (BS bs) =
-  let enc = R0.encode bs
-      us  = B16.decode enc
-      r0  = R0.decode enc
-  in  case us of
-        Nothing -> case r0 of
-          Left _ -> True
-          _ -> False
-        Just du -> case r0 of
-          Left _ -> False
-          Right d0 -> du == d0
+decode_inverts_encode :: BS -> Bool
+decode_inverts_encode (BS bs) =
+  let enc = B16.encode bs
+  in  all (\(_, dec) -> dec enc == Just bs) decoders
 
+decode_matches_reference :: Hexish -> Bool
+decode_matches_reference (Hexish bs) =
+  let r0 = ref_decode bs
+  in  B16.decode bs == r0 && Pure.decode bs == r0
+
+-- unit tests -----------------------------------------------------------------
+
 case_handled :: TestTree
-case_handled = H.testCase "decodes uppercase hex" $ do
-  let lhex = "deadbeef"
-      uhex = "DEADBEEF"
-  case liftA2 (,) (B16.decode lhex) (B16.decode uhex) of
-    Nothing -> H.assertBool mempty False
-    Just (a, b) -> H.assertEqual mempty a b
+case_handled = H.testCase "decodes uppercase and mixed-case hex" $
+  forM_ decoders $ \(nam, dec) -> do
+    let l = dec "deadbeef"
+    H.assertEqual nam (Just "\xde\xad\xbe\xef") l
+    H.assertEqual nam l (dec "DEADBEEF")
+    H.assertEqual nam l (dec "DeAdBeEf")
 
+-- every byte value, at every position of a string spanning one full NEON
+-- block plus a scalar tail, is accepted iff it is a hex char
+every_byte_every_position :: TestTree
+every_byte_every_position =
+  H.testCase "every byte at every position" $ do
+    let base = B8.replicate 34 '0'
+    forM_ [0 .. 255 :: Int] $ \c ->
+      forM_ [0 .. BS.length base - 1] $ \i -> do
+        let w   = fromIntegral c :: Word8
+            inp = BS.take i base <> BS.singleton w <> BS.drop (i + 1) base
+            pec = if is_hex w then ref_decode inp else Nothing
+            msg = "byte " <> show c <> ", position " <> show i
+        forM_ decoders $ \(nam, dec) ->
+          H.assertEqual (nam <> ", " <> msg) pec (dec inp)
+
+-- for every length spanning several NEON blocks and every tail length,
+-- a single invalid byte at any position causes decoding to fail
+single_corruption :: TestTree
+single_corruption = H.testCase "single invalid byte anywhere" $ do
+  let bad = BS.pack [0x00, 0x20, 0x7f, 0x80, 0xff] <> "/:@G`g"
+  forM_ [1 .. 80 :: Int] $ \n -> do
+    let raw = BS.pack (fmap fromIntegral [7 * j + 3 | j <- [1 .. n]])
+        enc = upper_odd (B16.encode raw)
+        upper_odd = BS.pack . zipWith up [0 :: Int ..] . BS.unpack
+        up j c | odd j && c >= 0x61 = c - 0x20
+               | otherwise          = c
+    forM_ decoders $ \(nam, dec) ->
+      H.assertEqual (nam <> ", intact, n = " <> show n) (Just raw) (dec enc)
+    forM_ [0 .. BS.length enc - 1] $ \i ->
+      forM_ (BS.unpack bad) $ \w -> do
+        let inp = BS.take i enc <> BS.singleton w <> BS.drop (i + 1) enc
+            msg = "n = " <> show n <> ", position " <> show i
+                  <> ", byte " <> show w
+        forM_ decoders $ \(nam, dec) ->
+          H.assertEqual (nam <> ", " <> msg) Nothing (dec inp)
+
+odd_lengths :: TestTree
+odd_lengths = H.testCase "odd-length inputs" $
+  forM_ [1, 3 .. 99 :: Int] $ \l ->
+    forM_ decoders $ \(nam, dec) ->
+      H.assertEqual (nam <> ", length " <> show l) Nothing
+        (dec (B8.replicate l 'a'))
+
 main :: IO ()
 main = defaultMain $
   testGroup "ppad-base16" [
     testGroup "property tests" [
-      Q.testProperty "decode . encode ~ id" $
-        Q.withMaxSuccess 5000 decode_inverts_encode
-    , Q.testProperty "encode matches reference" $
+      Q.testProperty "encode matches reference" $
         Q.withMaxSuccess 5000 encode_matches_reference
+    , Q.testProperty "decode . encode ~ id" $
+        Q.withMaxSuccess 5000 decode_inverts_encode
     , Q.testProperty "decode matches reference" $
         Q.withMaxSuccess 5000 decode_matches_reference
     ]
   , testGroup "unit tests" [
       case_handled
+    , every_byte_every_position
+    , single_corruption
+    , odd_lengths
     ]
   ]
-
