Extreme branchless: Mastermind
Continuing my branchless journey.
This time: Mastermind kata.
It works as follows:
- A codemaker choose a secret (4 colored pegs, among 6 colors)
- A codecracker mke a guess
- The codemaker gives the number of well-placed and misplaced colors
Let's bootstrap some tests:
describe "Mastermind" $ do
forM_ [
(["blue"], ["blue"], (1, 0))
] $ \(secret, guess, expected) ->
it ("with secret " <> show secret <> " guessing " <> show guess <> " should evaluate to " <> show expected) $
evaluate secret guess `shouldBe` expected
We can hard-code the response:
evaluate secret guess = (1, 0)
newtype Color
= Color String
deriving newtype (Eq, Ord, Show, IsString)
Then, we can add one color well-placed, and get the length to make it pass:
-- (["blue", "red"], ["blue", "red"], (2, 0))
evaluate secret guess = (length secret, 0)
The next interesting use-case is to have a color not present in the secret, filtering is simple to make it work:
-- (["blue", "red"], ["blue", "yellow"], (1, 0))
evaluate secret guess = (length $ filter (uncurry (==)) joined, 0)
where joined = zip secret guess
Note: zip will "join" secret/guess element by element in tuples,
then we can compare them one by one.
We have to tackle misplaced colors:
-- (["blue", "red"], ["yellow", "blue"], (0, 1))
evaluate secret guess = (length wellPlaced, length misplaced)
where joined = zip secret guess
wellPlaced = filter (uncurry (==)) joined
(extraSecret, extraGuess) = unzip $ filter (uncurry (/=)) joined
misplaced = filter (`elem` extraSecret) extraGuess
The first thing is to collect non-matching colors.
We then separate non-matching secret/guess elements with unzip.
Finally, count the number of wrong guess are in remaining secrets.
Then, we have to check that, each misplaced color, has the same number of
remaining secrets:
-- (["blue", "blue", "orange", "orange"], ["blue", "yellow", "blue", "blue"], (1, 1))
evaluate secret guess = (length wellPlaced, missplacedCount)
where joined = zip secret guess
wellPlaced = filter (uncurry (==)) joined
(extraSecret, extraGuess) = unzip $ filter (uncurry (/=)) joined
toMap = Map.fromListWith (+) . flip zip (repeat 1)
missplacedCount = sum $ Map.elems $ Map.intersectionWith min (toMap extraSecret) (toMap extraGuess)
The best way to achieve this is to rely on a Map Color, count the number of
elements on each side and join by min.
This implementation is short enough, but there are many branches in the functions I have used.
Let's break this down, instead of filtering elements, we can accumulate a partial result we would be able to combined later, such as:
type Accumulator = (Sum Int, (Misplaced, Misplaced))
Misplaced contains a list, color-by-color count of misplaced elements:
data Misplaced = Misplaced
{ blues :: Int
, reds :: Int
, yellows :: Int
, oranges :: Int
, greens :: Int
, purples :: Int
}
deriving stock (Show)
x <> y =
Misplaced
{ blues = x.blues + y.blues
, reds = x.reds + y.reds
, yellows = x.yellows + y.yellows
, oranges = x.oranges + y.oranges
, greens = x.greens + y.greens
, purples = x.purples + y.purples
}
mempty =
Misplaced
{ blues = 0
, reds = 0
, yellows = 0
, oranges = 0
, greens = 0
, purples = 0
}
We should add a function to get the definitive count:
countMisplaced x y =
min x.blues y.blues
+ min x.reds y.reds
+ min x.yellows y.yellows
+ min x.oranges y.oranges
+ min x.greens y.greens
+ min x.purples y.purples
Then, we should redesign Color, so it includes:
- A function to get an
Accumulator, on a comparison with anotherColor - An
AccumulatorperColor
Which gives this definition:
data Color = Color
{ name :: String
, compareColor :: Color -> Accumulator
, onBlue :: Misplaced -> Accumulator
, onRed :: Misplaced -> Accumulator
, onYellow :: Misplaced -> Accumulator
, onOrange :: Misplaced -> Accumulator
, onGreen :: Misplaced -> Accumulator
, onPurple :: Misplaced -> Accumulator
}
show = (.name)
Then we can build each Color, defaulting to errors and overriding fields
according to their color:
mkColor name' m access alter =
alter $
Color
{ name = name'
, compareColor = \other -> access other m
, onBlue = defaultOn
, onRed = defaultOn
, onYellow = defaultOn
, onOrange = defaultOn
, onGreen = defaultOn
, onPurple = defaultOn
}
where defaultOn m' = (Sum 0, (m, m'))
matchingAccumulator _ = (Sum 1, (mempty, mempty))
Which gives:
blue, red, yellow, orange, green, purple :: Color
blue = mkColor "blue" (mempty { blues = 1 }) (.onBlue) (\x -> x { onBlue = matchingAccumulator })
red = mkColor "red" (mempty { reds = 1 }) (.onRed) (\x -> x { onRed = matchingAccumulator })
yellow = mkColor "yellow" (mempty { yellows = 1 }) (.onYellow) (\x -> x { onYellow = matchingAccumulator })
orange = mkColor "orange" (mempty { oranges = 1 }) (.onOrange) (\x -> x { onOrange = matchingAccumulator })
green = mkColor "green" (mempty { greens = 1 }) (.onGreen) (\x -> x { onGreen = matchingAccumulator })
purple = mkColor "purple" (mempty { purples = 1 }) (.onPurple) (\x -> x { onPurple = matchingAccumulator })
Let's use it in our tests:
spec =
describe "Mastermind" $ do
forM_ [
([blue], [blue], (1, 0))
, ([blue, red], [blue, red], (2, 0))
, ([blue, red], [blue, yellow], (1, 0))
, ([blue, red], [yellow, blue], (0, 1))
, ([blue, red, orange], [blue, yellow, blue], (1, 0))
, ([blue, blue, orange, orange], [blue, yellow, blue, blue], (1, 1))
] $ \(secret, guess, expected) ->
it ("with secret " <> show secret <> " guessing " <> show guess <> " should evaluate to " <> show expected) $
evaluate secret guess `shouldBe` expected
Finally, let's rewrite evaluate:
evaluate secret guess =
bimap getSum (uncurry countMisplaced)
$ mconcat
$ zipWith (.compareColor) secret guess
It works as follows:
- We compare element by element with the built-in function
- We get a list of
Accumulators we combine into one thoughmconcat(which relies onMonoid) - Finally, we unwrap the well-placed
SumandcountMisplacedtotal
Here we are!