10.09.2018

Mastermind.py - Code Reviewing Myself

It's your typical family-evening: Everybody is at home, somewhere in the living room. And then your brother exclaims that he's got a tricky puzzle for you.

The Mastermind Attempts

There's only one solution.
It took me forever to figure it out!
Duplicates are possible, and we're using the pin colors yellow, red, orange, blue, green, brown, black, white, or just a hole.

You glance at it for a minute and quickly judge: You'd probably be faster at writing a script that solves this, than doing it yourself. And it would also be more fun.
The goal is to figure out the correct four pins - order is relevant. A black pin on the left side means that one guessed pin is at the correct position. A white pin means that one guessed pin is of the right color but at the wrong position.


How I solved it and what I learnt


								#!/usr/bin/env python
								import itertools

								yellow = "yellow"
								hole = "hole"
								red = "red"
								orange = "orange"
								brown = "brown"
								blue = "blue"
								black = "black"
								white = "white"
								green = "green"
								NUM_PLACES = 4
								COLORS = [yellow, hole, red, orange, brown, blue, black, white, green]
								CASES_GIVEN = [
										([yellow, orange, red, blue],[1,2,1]),
										([yellow, red, orange, hole],[0,2,2]),
										([red, orange, blue, brown], [1,1,2]),
										([blue, orange, yellow, white], [1,1,2]),
										([blue, yellow, red, black], [1,2,1]),
										]
							

First of all, let's define our colors and how many pins we have to guess, so that we can later change that - just in case I forgot a color, I wouldn't want to go and change the whole file.

I defined a variable for every color, just so I would get a useful error message in case I'd make a typo somewhere.

I decided to store the knowledge about the situation in a list of tuples. Each attempt is represented by one tuple, consisting of

  • The guesses in an ordered list from left to right
  • The result of that attempt in numbers. [black, white, nope]

Here comes the first insight!
It would have been smarter to use a Tuple () for the result, instead of a list [].
Lists are ordered and mutable; they usually contain things of the same type. Tuples are structured and immutable; they usually are used for things that serve different purposes.
A list is not that far off my purpose, but you'll see later that I got confused about which one to use, which resulted in a few unneccessary conversions and confusion.


The Logic behind almost the whole program is in the check_case function.
We take a semi-random guess at what the correct solution might be.
Then we have a look at the first attempt and generate a result of black and white pins. If our result doesn't match the one from the actual attempt, our solution guess is wrong and we need to try another one.

On line 7 comes another insight.
We first go through every position and increase our black (correct) result counter by one.
It is important that this comes first, in its own loop, because otherwise we could become confused in a case like red, blue, red where the actual solution would be green, red, red: We would first consider the first red pin and consider it to be worthy of a white result pin. Later, we would consider the last red and not be allowed to give it a black result pin. That's why we do that first.

We stored the remaining pins to be evaluated in case_white and iterate over that list. In order to have only as many matches as there are in the solution, we remove a pin of that color from the solution.
And there lies the caveat. Modifying the solution_ which we received as a parameter would edit it, since it's only passed by the list's reference. Hence the deep copy on line 7.

And by the way, in an intermediary state of this code, I was iterating over a list and removing elements from it within that loop. Thats never a good idea either.


								#case_array of the form ["red", "black", "blue", "green"]
								def check_case(solution_, case_array):
									black = 0
									white = 0
									nope = 0
									case_white = []
									solution = list(solution_) # deep copy

									# check for black, keep all others
									for position in range(len(case_array)):
										if solution[position] == case_array[position]:
											black+=1
										else:
											case_white.append(case_array[position])
									
									# check for white
									for pin in case_white:
										if pin in solution:
											solution.remove(pin)
											white+=1
										else:
											nope+=1
									return (black, white, nope)
							


								# true if solution fulfills case
								def test_case(solution, given_case):
									(assignment, result) = given_case
									return ( list(check_case(solution, assignment)) == result )

								def test_all_cases(solution):
									return all(
											list(
													map(lambda case_given: test_case(solution, case_given) , CASES_GIVEN)
												)
											)
							

This part should be pretty clear. test_case compares our computed result with the result from the image above. If it matches, we're good.
Note that I needed to make a list from our computed tuple because I didn't think of returning a list when I was writing check_case - or of using a tuple in the starting definitions. This is just a quick-and-dirty fix on line 4.

Given some guessed solution, test_all_cases computes that result for every attempt that we were given in the image.
If any of its content is False, all can shortcut and just say False, the predicate does not hold for all elements. As of Python 3, map is a generator. That means that it evaluates lazily as well.
Converting it to a list first was a mistake with a performance cost.


strl is just a function for formatting my output, because I didn't realize I could just do print("Solution: "+str(my_tuple)).

try_all_solutions is the entry point of the program. We have all the building blocks, and this function builds the house.

Since itertools provides us with a generator containing all possible permutations of COLORS, order mattering, we can simply loop over all these possible_solutions and test each one. Once one is found, we can return.
Simple brute-force approach.

It was nice to then proceed to play around with this script. For example, I wouldn't have noticed manually that the second test case is redundant. We could completely ignore the second attempt and still, only one solution is possible.
It was also slightly surprising that there was actually only one possible solution, despite the fact that green never appears in any test case.


								def strl(sol):
									res = "( "
									for t in range(len(sol)):
										res = res + sol[t] + ", "
									return res+")"
							
								def try_all_solutions():
									possible_solutions = itertools.product(COLORS, repeat=NUM_PLACES)
									print("---")
									for solution in list(possible_solutions):
										found = test_all_cases(list(solution))
										#print("tested solution" + strl(solution))
										if found:
											print("Solution: " + strl(solution))
											# return here to stop at first solution
											#return
									print("loop done")
									return
							

This, including figuring out all these problems in order to learn python a bit better, took me about 2 hours.
That was as long as it took my other family members. But I learnt something in the meantime.
If anybody is reading this, I hope so did you.

All code on this page is released under CC-BY-SA