Let’s fix that reminder test and set up to allow us to select techniques. A bit of progress, and raggedness tells me I need a break. It’s artificial, it’s intelligent, and it’s not AI.

We have this test failing:

    def test_full_3x3(self):
        Puzzle.puzzle_count = 0
        Solver.solver_count = 0
        puzzle = Puzzle('103000001')
        solver = Solver(puzzle)
        solved = solver.solve()
        assert solved.game == '123312231'
        assert Solver.solver_count == 10
        assert Puzzle.puzzle_count == 10

It’s there to remind us that our SudokuTechniques object interferes with tests of some low-level operations.

When last we met, what I had in mind was making the techniques into a kind of list, so that we could readily control which were applied. I had in mind changing Solver so that it would have a list of techniques to execute. But it occurs to me that we may also wish to allow for running “techniques” without our brute force method. After all, we’d like to solve puzzles intelligently if we can.

So, therefore, it seems to me that the Solver itself should be one of the techniques. Let’s try something. Here’s Solver:

class Solver:
    solver_count = 0

    def __init__(self, puzzle):
        Solver.solver_count += 1
        self.puzzle = puzzle

    def solve(self) -> Puzzle | None:
        if self.puzzle.is_filled_in:
            return self.puzzle
        for new_puzzle in self.puzzle.find_next_puzzles():
            techniques = SudokuTechniques(new_puzzle)
            improved_puzzle = techniques.apply()
            solved_puzzle = Solver(improved_puzzle).solve()
            if solved_puzzle:
                return solved_puzzle
        return None

Let’s first extract almost the complete contents of solve to a new method that we’ll call brute_solve:

    def solve(self) -> Puzzle | None:
        if self.puzzle.is_filled_in:
            return self.puzzle
        return self.brute_solve()

    def brute_solve(self):
        for new_puzzle in self.puzzle.find_next_puzzles():
            techniques = SudokuTechniques(new_puzzle)
            improved_puzzle = techniques.apply()
            solved_puzzle = Solver(improved_puzzle).solve()
            if solved_puzzle:
                return solved_puzzle
        return None

Now let’s see … let’s assume that Solver has a list of techniques to try, and that we loop over them. But wait! That’s not going to suffice. Suppose we have a few techniques. We’ll want to try the first one until it finds nothing to do, then try the second. If the second finds something to do … don’t we want to go back and try the first one again?

And as for the brute force method, we have seen that once we make a guess with it, there’s an advantage to running the others. So we need each technique to do its best before returning, and in the case of the brute force one, it, too, should just make one guess and then loop back to the others.

Let’s create a list in Solver and iterate it.

This is ragged, and I don’t like it, but I think it works:

class Solver:
    solver_count = 0

    def __init__(self, puzzle, techniques=['brute_solve']):
        Solver.solver_count += 1
        self.puzzle = puzzle
        self.techniques = techniques

    def solve(self) -> Puzzle | None:
        if self.puzzle.is_filled_in:
            return self.puzzle
        for technique in self.techniques:
            if technique == 'force_solve':
                techniques = SudokuTechniques(self.puzzle)
                self.puzzle = techniques.apply()
            if technique == 'brute_solve':
                self.puzzle = self.brute_solve(self.puzzle)
        return self.puzzle

    def brute_solve(self, puzzle):
        for new_puzzle in self.puzzle.find_next_puzzles():
            solved_puzzle = Solver(new_puzzle).solve()
            if solved_puzzle:
                return solved_puzzle
        return None

We are green. Before I commit, I want to check that the full list works. I’ll do that by checking the big 9x9 and looking for the smaller value of solutions.

Meh. I thought it was working with the force in place, but it was not. I’ve lost the thread. Roll back. I am really glad I didn’t commit anything.

I could try to debug that but let’s try thinking again.

Let’s first just pass in a flag that says whether to run the force or not.

class Solver:
    solver_count = 0

    def __init__(self, puzzle, apply_techniques=False):
        Solver.solver_count += 1
        self.apply_techniques = apply_techniques
        self.puzzle = puzzle

    def solve(self) -> Puzzle | None:
        if self.puzzle.is_filled_in:
            return self.puzzle
        for new_puzzle in self.puzzle.find_next_puzzles():
            if self.apply_techniques:
                techniques = SudokuTechniques(new_puzzle)
                improved_puzzle = techniques.apply()
            solved_puzzle = Solver(improved_puzzle).solve()
            if solved_puzzle:
                return solved_puzzle
        return None

PyCharm informs me that improved_puzzle is not necessarily defined. I think we can use new_puzzle there. Let’s get a test in place first, however:

    def test_first_9x9_with_technique(self):
        assert self.puzzle_list == self.puzzle_list_2  # ensuring no typos
        puzzle = Puzzle.from_list(self.puzzle_list)
        Solver.solver_count = 0
        solver = Solver(puzzle)
        solved_puzzle = solver.solve()
        assert solved_puzzle.is_correctly_solved
        assert Solver.solver_count < 4000

That fails as intended:

Expected :4000
Actual   :78431

And then:

    def test_first_9x9_with_technique(self):
        assert self.puzzle_list == self.puzzle_list_2  # ensuring no typos
        puzzle = Puzzle.from_list(self.puzzle_list)
        Solver.solver_count = 0
        solver = Solver(puzzle, apply_techniques=True)
        solved_puzzle = solver.solve()
        assert solved_puzzle.is_correctly_solved
        assert Solver.solver_count < 4000

WTF? It still fails. Same 78431. Ah. Need to pass the flag along:

    def solve(self) -> Puzzle | None:
        if self.puzzle.is_filled_in:
            return self.puzzle
        for new_puzzle in self.puzzle.find_next_puzzles():
            if self.apply_techniques:
                techniques = SudokuTechniques(new_puzzle)
                new_puzzle = techniques.apply()
            solved_puzzle = Solver(new_puzzle, self.apply_techniques).solve()
            if solved_puzzle:
                return solved_puzzle
        return None

Now we are green. Commit: techniques are now optional. Performance improves when applied.

That was ragged enough that I think I’ll do well to stop. Let’s sum up:

Summary

  1. Quit when you’re ahead, if you ever get ahead.
  2. Quit when things are getting ragged: you’re probably tired.
  3. Quit when confused, or at least slow down and take smaller steps.

Here, somehow, making a list of actions to take didn’t work out. I suspect it was due to a failure to properly pass a result along, or perhaps the flag list, or something like that. But I don’t care what it was, because I threw it away and did something simpler. And I nearly messed that up as well.

I take that as a clear sign that I should take a break before I break something. So I will do just that. See you next time!