MadMode

Dan Connolly's tinkering lab notebook

studying Knuth's Mastermind Solver with rust

To celebrate rust turning 1.0, here's what I learned with mmind5, a study of Knuth's five guess algorithm for mastermind.

While I had my share of frustration with the borrow-checker, the rust type system is expressive enough that code is typically correct once it compiles. I had only one bug fix in the whole project.

My first commit was codemaker chooses a random Pattern of CodePegs:

    #[derive_Rand]
    #[derive(Debug)]
    enum CodePeg {
      Red, Orange, Yellow,
      Green, Blue, White
    }

    #[derive(Debug)]
    struct Pattern {
      pegs: [CodePeg; 4]
    }

    impl Rand for Pattern {
        fn rand<R: Rng>(rng: &mut R) -> Self {
            Pattern {
                pegs: [CodePeg::rand(rng),
                       CodePeg::rand(rng),
                       CodePeg::rand(rng),
                       CodePeg::rand(rng)]
            }
        }
    }

But as soon as I started working on scoring a guess vs. the code and wanted to iterate over the pegs, my next commit was abandon fixed sized array in favor of vec.

Then we get nice functional code for scoring blacks:

    let rightColorAndPlace = (0..Pattern::size()).map(|pos| {
        if g[pos] == s[pos] { Some(KeyPeg::Black) }
        else { None }
    }).collect();

White scoring is more involved; I worked it out using sloppy println!() debugging. More on testing below.

As I started to study the algorithm, I changed the representation of Pattern to a single u32 representing the (lexicographic) index of the pattern, converting to a vector of pegs as needed for scoring. And I punted on deriving Rand.

The rust std::collections::BitSet was a good match for ... the set S of 1296 possible codes, 1111,1112,.., 6666.

I got the first five steps of the algorithm working on the first night; or so I thought. On the second night, I got the final minmax step coded up and fixed that bug in step 5 (remove_mismatches) and tada! It works.

Once I got it working, I have found any number of ways to clarify the code by refactoring. Each time, it was a matter of making one isolated change and letting the compiler guide me through the rest of the places in the code that needed fixing.

For example, I had conversion from patterns to indexes and back mixed in with scoring logic:

    let mut guesses_with_score = HashMap::new();
    for guess_ix in 0..Pattern::cardinality() {
        if !self.guessed.contains(&Pattern::ith(guess_ix)) {
            let score = guess_score(guess_ix);
            let guess = Pattern::ith(guess_ix);
            guesses_with_score.entry(score).or_insert(vec![]).push(guess)
        }
    }

I was able to factor out PatternSet, hiding the BitSet of indexes, so the solver logic looks like:

    let mut guesses_with_score = HashMap::new();
    for guess in Pattern::range() {
        if !self.guessed.contains(&guess) {
            let score = guess_score(guess);
            guesses_with_score.entry(score).or_insert(vec![]).push(guess)
        }
    }

Implementing Iterator for Solver worked nicely, but doing IntoIterator for PatternSet stumped me. It's frustrating: all I wanted to do was factor out the expression Pattern::range().filter(|p| self.s.contains(p)) as into_iter on s, but its type is a monster to write out and I never did get the associated types and lifetimes figured out.

It seems to make two or three guesses per second, which seems pretty speedy, considering it seems to be O(N^2) where N = 1296. Now that I think about it, those were debug builds. A release build takes a small part of a second to solve the whole thing:

mmind$ time target/release/mmind 
codemaker: 4112
turn 1:    1122  BBW
turn 2:    1223  WW
turn 3:    4115  BBB
turn 4:    4112  BBBB

real    0m0.026s
user    0m0.026s
sys 0m0.000s

This is what I would see during development:

mmind$ time target/debug/mmind
codemaker: 1553
turn 1:    1122  B
turn 2:    1344  BW
turn 3:    4524  B
turn 4:    1336  BW
turn 5:    1553  BBBB

real    0m3.804s
user    0m3.793s
sys 0m0.004s

Another thing that felt slow was example documentation tests. Having support for them is great; python doctest got me addicted to this style. But testing them seems to rely on having the crate built; i.e. cargo test isn't enough; I had to do cargo build; cargo test. And to see the documentation, it becomes cargo build; cargo test; cargo doc.

I'm also addicted to emacs and flycheck-mode. flycheck-rust works pretty well but helping it find the crate root is a little fidgety.