diff --git a/demo/Ints/Main.hs b/demo/Ints/Main.hs
deleted file mode 100644
--- a/demo/Ints/Main.hs
+++ /dev/null
@@ -1,66 +0,0 @@
-module Ints.Main where
-
-import           System.Random                 (RandomGen, mkStdGen, random)
-
-import           Data.IntSet                   (IntSet)
-import qualified Data.IntSet                   as IntSet
-import qualified Data.PerfectHash.Construction as Construction
-import qualified Data.PerfectHash.Lookup       as Lookup
-import qualified Data.Vector.Unboxed           as Vector
-import qualified Exercise
-
-
-valueCount = 250000
-
-
-data RandIntAccum t = RandIntAccum
-  t -- ^ random number generator
-  Int -- ^ max count
-  IntSet -- ^ accumulated unique random numbers
-
-
--- | Since computing the size of the set is O(N), we
--- maintain the count separately.
-getUniqueRandomIntegers :: RandomGen t => RandIntAccum t -> IntSet
-getUniqueRandomIntegers (RandIntAccum std_gen count current_set) =
-
-  if count == 0
-    then current_set
-    else getUniqueRandomIntegers newstate
-
-  where
-    (next_int, next_std_gen) = random std_gen
-
-    a = RandIntAccum next_std_gen
-    newstate = if IntSet.member next_int current_set
-      then a count current_set
-      else a (count - 1) (IntSet.insert next_int current_set)
-
-
-intMapTuples :: [(Int, Int)]
-intMapTuples = zip random_ints [1..]
-  where
-    seed_value = RandIntAccum (mkStdGen 0) valueCount IntSet.empty
-    random_ints = IntSet.toList $ getUniqueRandomIntegers seed_value
-
-
-main = do
-  putStrLn $ unwords ["Keys size:", show $ length intMapTuples]
-
-  let lookup_table = Construction.createMinimalPerfectHash intMapTuples
-
-  putStrLn $ unwords [
-      "Finished computing lookup table with"
-    , show $ Lookup.size lookup_table
-    , "entries."
-    ]
-
-  let direct_mapping_nonces = Vector.filter (< 0) $ Lookup.nonces lookup_table
-
-  putStrLn $ unwords [
-      "There were"
-    , show $ Vector.length direct_mapping_nonces
-    , "lookup entries with direct mappings."
-    ]
-
-  Exercise.eitherExit $ Exercise.testLookups lookup_table intMapTuples
diff --git a/demo/Strings/Main.hs b/demo/Strings/Main.hs
deleted file mode 100644
--- a/demo/Strings/Main.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-module Strings.Main where
-
-import           Control.Monad                 (when)
-
-import qualified Data.PerfectHash.Construction as Construction
-import qualified Data.PerfectHash.Lookup       as Lookup
-import qualified Exercise
-
-
-enableDebug = False
-
-dictionaryPath = "/usr/share/dict/words"
-
-
-main = do
-
-  word_index_tuples <- Exercise.wordsFromFile dictionaryPath
-
-  putStrLn $ unwords ["Words size:", show $ length word_index_tuples]
-
-  let lookup_table = Construction.createMinimalPerfectHash word_index_tuples
-
-  putStrLn $ unwords [
-      "Finished computing lookup table with"
-    , show $ Lookup.size lookup_table
-    , "entries."
-    ]
-
-  when enableDebug $ do
-    putStrLn $ unwords ["Vector G:", show $ Lookup.nonces lookup_table]
-    putStrLn $ unwords ["Vector V:", show $ Lookup.values lookup_table]
-
-  Exercise.eitherExit $ Exercise.testLookups lookup_table word_index_tuples
diff --git a/demo/ints/Main.hs b/demo/ints/Main.hs
new file mode 100644
--- /dev/null
+++ b/demo/ints/Main.hs
@@ -0,0 +1,66 @@
+module Main where
+
+import           System.Random                 (RandomGen, mkStdGen, random)
+
+import           Data.IntSet                   (IntSet)
+import qualified Data.IntSet                   as IntSet
+import qualified Data.PerfectHash.Construction as Construction
+import qualified Data.PerfectHash.Lookup       as Lookup
+import qualified Data.PerfectHash.TestUtils    as Exercise
+import qualified Data.Vector.Unboxed           as Vector
+
+
+valueCount = 250000
+
+
+data RandIntAccum t = RandIntAccum
+  t -- ^ random number generator
+  Int -- ^ max count
+  IntSet -- ^ accumulated unique random numbers
+
+
+-- | Since computing the size of the set is O(N), we
+-- maintain the count separately.
+getUniqueRandomIntegers :: RandomGen t => RandIntAccum t -> IntSet
+getUniqueRandomIntegers (RandIntAccum std_gen count current_set) =
+
+  if count == 0
+    then current_set
+    else getUniqueRandomIntegers newstate
+
+  where
+    (next_int, next_std_gen) = random std_gen
+
+    a = RandIntAccum next_std_gen
+    newstate = if IntSet.member next_int current_set
+      then a count current_set
+      else a (count - 1) (IntSet.insert next_int current_set)
+
+
+intMapTuples :: [(Int, Int)]
+intMapTuples = zip random_ints [1..]
+  where
+    seed_value = RandIntAccum (mkStdGen 0) valueCount IntSet.empty
+    random_ints = IntSet.toList $ getUniqueRandomIntegers seed_value
+
+
+main = do
+  putStrLn $ unwords ["Keys size:", show $ length intMapTuples]
+
+  let lookup_table = Construction.createMinimalPerfectHash intMapTuples
+
+  putStrLn $ unwords [
+      "Finished computing lookup table with"
+    , show $ Lookup.size lookup_table
+    , "entries."
+    ]
+
+  let direct_mapping_nonces = Vector.filter (< 0) $ Lookup.nonces lookup_table
+
+  putStrLn $ unwords [
+      "There were"
+    , show $ Vector.length direct_mapping_nonces
+    , "lookup entries with direct mappings."
+    ]
+
+  Exercise.eitherExit $ Exercise.testLookups lookup_table intMapTuples
diff --git a/demo/strings/Main.hs b/demo/strings/Main.hs
new file mode 100644
--- /dev/null
+++ b/demo/strings/Main.hs
@@ -0,0 +1,33 @@
+module Main where
+
+import           Control.Monad                 (when)
+
+import qualified Data.PerfectHash.Construction as Construction
+import qualified Data.PerfectHash.Lookup       as Lookup
+import qualified Data.PerfectHash.TestUtils    as Exercise
+
+
+enableDebug = False
+
+dictionaryPath = "/usr/share/dict/words"
+
+
+main = do
+
+  word_index_tuples <- Exercise.wordsFromFile dictionaryPath
+
+  putStrLn $ unwords ["Words size:", show $ length word_index_tuples]
+
+  let lookup_table = Construction.createMinimalPerfectHash word_index_tuples
+
+  putStrLn $ unwords [
+      "Finished computing lookup table with"
+    , show $ Lookup.size lookup_table
+    , "entries."
+    ]
+
+  when enableDebug $ do
+    putStrLn $ unwords ["Vector G:", show $ Lookup.nonces lookup_table]
+    putStrLn $ unwords ["Vector V:", show $ Lookup.values lookup_table]
+
+  Exercise.eitherExit $ Exercise.testLookups lookup_table word_index_tuples
diff --git a/perfect-hash-generator.cabal b/perfect-hash-generator.cabal
--- a/perfect-hash-generator.cabal
+++ b/perfect-hash-generator.cabal
@@ -2,10 +2,10 @@
 --
 -- see: https://github.com/sol/hpack
 --
--- hash: 1698f1d36979cbf05571994d0eceb9f922608eac19a6c04d98a86117dc5f37cf
+-- hash: 3429fd1131c7e55998ad9391be6e8af0010ce07ec53e20ea840551bc47a942ac
 
 name:           perfect-hash-generator
-version:        0.2.0.3
+version:        0.2.0.4
 synopsis:       Perfect minimal hashing implementation in native Haskell
 description:     A <https://en.wikipedia.org/wiki/Perfect_hash_function perfect hash function> for a set @S@ is a hash function that maps distinct elements in @S@ to a set of integers, with __no collisions__. A <https://en.wikipedia.org/wiki/Perfect_hash_function#Minimal_perfect_hash_function minimal perfect hash function> is a perfect hash function that maps @n@ keys to @n@ __consecutive__ integers, e.g. the numbers from @0@ to @n-1@.
                 .
@@ -70,15 +70,15 @@
       Data.PerfectHash.Construction
       Data.PerfectHash.Hashing
       Data.PerfectHash.Lookup
+      Data.PerfectHash.TestUtils
   other-modules:
       Paths_perfect_hash_generator
   default-language: Haskell2010
 
 executable hash-perfectly-ints-demo
-  main-is: Ints/Main.hs
+  main-is: Main.hs
   hs-source-dirs:
-      demo
-      test
+      demo/ints
   ghc-options: -fwarn-tabs -W
   build-depends:
       base >=4.5 && <5
@@ -93,17 +93,13 @@
     , unordered-containers
     , vector
   other-modules:
-      Strings.Main
-      Exercise
-      Main
       Paths_perfect_hash_generator
   default-language: Haskell2010
 
 executable hash-perfectly-strings-demo
-  main-is: Strings/Main.hs
+  main-is: Main.hs
   hs-source-dirs:
-      demo
-      test
+      demo/strings
   ghc-options: -fwarn-tabs -W
   build-depends:
       base >=4.5 && <5
@@ -117,9 +113,6 @@
     , unordered-containers
     , vector
   other-modules:
-      Ints.Main
-      Exercise
-      Main
       Paths_perfect_hash_generator
   default-language: Haskell2010
 
@@ -143,6 +136,5 @@
     , unordered-containers
     , vector
   other-modules:
-      Exercise
       Paths_perfect_hash_generator
   default-language: Haskell2010
diff --git a/src/Data/PerfectHash/TestUtils.hs b/src/Data/PerfectHash/TestUtils.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/PerfectHash/TestUtils.hs
@@ -0,0 +1,46 @@
+-- | This module is not used by the main library for constructing perfect hashes;
+-- it just provides convenience functions for use by the tests and demonstration executables.
+module Data.PerfectHash.TestUtils where
+
+import           Control.Monad            (unless)
+import           Data.Foldable            (traverse_)
+import qualified Data.Vector.Unboxed      as Vector
+
+import qualified Data.PerfectHash.Hashing as Hashing
+import qualified Data.PerfectHash.Lookup  as Lookup
+
+
+testLookups :: (Show b, Eq b, Show a, Hashing.ToHashableChunks a, Vector.Unbox b) =>
+     Lookup.LookupTable b
+  -> [(a, b)]
+  -> Either String ()
+testLookups lookup_table =
+  traverse_ check_entry
+  where
+    check_entry (word, source_index) = unless (lookup_result == source_index) $
+      Left $ unwords [
+          "Result for key"
+        , show word
+        , "had incorrect index"
+        , show lookup_result
+        , "; should have been"
+        , show source_index
+        ]
+      where
+        lookup_result = Lookup.lookupPerfect lookup_table word
+
+
+-- | Generate a map of words from a file to their line numbers.
+--
+-- Intended for use with @\"/usr/share/dict/words\"@.
+wordsFromFile :: FilePath -> IO [(String, Int)]
+wordsFromFile path = do
+  file_lines <- readFile path
+  let word_index_tuples = zip (lines file_lines) [1..]
+  return word_index_tuples
+
+
+eitherExit :: Either String b -> IO ()
+eitherExit x = case x of
+  Left err -> error err
+  Right _ -> return ()
diff --git a/test/Exercise.hs b/test/Exercise.hs
deleted file mode 100644
--- a/test/Exercise.hs
+++ /dev/null
@@ -1,44 +0,0 @@
-module Exercise where
-
-import           Control.Monad            (unless)
-import           Data.Foldable            (traverse_)
-import qualified Data.Vector.Unboxed      as Vector
-
-import qualified Data.PerfectHash.Hashing as Hashing
-import qualified Data.PerfectHash.Lookup  as Lookup
-
-
-testLookups :: (Show b, Eq b, Show a, Hashing.ToHashableChunks a, Vector.Unbox b) =>
-     Lookup.LookupTable b
-  -> [(a, b)]
-  -> Either String ()
-testLookups lookup_table =
-  traverse_ check_entry
-  where
-    check_entry (word, source_index) = unless (lookup_result == source_index) $
-      Left $ unwords [
-          "Result for key"
-        , show word
-        , "had incorrect index"
-        , show lookup_result
-        , "; should have been"
-        , show source_index
-        ]
-      where
-        lookup_result = Lookup.lookupPerfect lookup_table word
-
-
--- | Generate a map of words from a file to their line numbers.
---
--- Intended for use with @\"/usr/share/dict/words\"@.
-wordsFromFile :: FilePath -> IO [(String, Int)]
-wordsFromFile path = do
-  file_lines <- readFile path
-  let word_index_tuples = zip (lines file_lines) [1..]
-  return word_index_tuples
-
-
-eitherExit :: Either String b -> IO ()
-eitherExit x = case x of
-  Left err -> error err
-  Right _ -> return ()
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -12,7 +12,7 @@
 
 import qualified Data.PerfectHash.Construction  as Construction
 import qualified Data.PerfectHash.Hashing       as Hashing
-import qualified Exercise
+import qualified Data.PerfectHash.TestUtils     as Exercise
 
 
 testHashComputation :: (Hashing.ToHashableChunks a, Show a) =>
