Sudoku: Lonely
If, in the candidates for a given component, a value appears in only one candidate list, then that value must be assigned to the corresponding cell.
I plan to implement a Technique today for the above rule. I’m not sure what the rule is called. I shall call it “lonely” until I find out what Sudoku gurus call it. Note that this is different from our “force” rule, which says that if any given candidate list has only one element, that element must be assigned to the corresponding cell.
Let’s set up a test.
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
pretty_print(puzzle.game)
assert False
That provides, for your interest and delectation:
| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
Looks good so far. Let’s examine the “second” row. Its second-to-last candidates should include ‘1’ and no other candidates in that row should have ‘1’:
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000', # <===
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
candidates = [row_1.candidates_at(p) for p in range(9)]
assert candidates == []
This prints just what I was hoping for:
[['2', '3', '4', '5', '6', '7', '8', '9'],
['2', '3', '4', '5', '6', '7', '8', '9'],
['2', '3', '4', '5', '6', '7', '8', '9'],
['2', '3', '4', '5', '6', '7', '8', '9'],
['2', '3', '4', '5', '6', '7', '8', '9'],
['2', '3', '4', '5', '6', '7', '8', '9'],
['2', '3', '4', '5', '6', '7', '8', '9'],
['1', '2', '3', '4', '5', '6', '7', '8', '9'],
['2', '3', '4', '5', '6', '7', '8', '9']] != []
So now we see what the “lonely” notion is. Although all these cells currently have lots of candidates, so that you’d think we could never assign any of them, only one cell in the row will allow a ‘1’. Therefore the ‘1’ must go there. Neat, huh?
If a human were scanning that row and wondering where the one could go … she would be like “no, no, …, yes!, no, there you are you little devil!” Our mission, and we choose to accept it, is to make that same determination.
The question in my mind is how to do it. A closely related question is what objects should have what responsibility for doing it.
I think we have a start at a place, in SudokuTechniques. Wait. I want to make the test pass, because I think it’s going to give me an idea. Maybe.
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
candidates = [row_1.candidates_at(p) for p in range(9)]
all_but_7 = [candidates[cell] for cell in range(9) if cell != 7]
for candidate in all_but_7:
assert '1' not in candidate
assert '1' in candidates[7]
That’s passing. I think that can be simpler.
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
all_but_7 = [row_1.candidates_at(cell) for cell in range(9) if cell != 7]
for candidate in all_but_7:
assert '1' not in candidate
assert '1' in row_1.candidates_at(7)
Green. Commit: test working toward “lonely”.
This test shows one way to ascertain whether a given value, ‘1’ in our case, is lonely in a given row, in a given cell, 7 in our case. It does not appear in any other candidates list in the row and does occur in 7.
Let’s keep in mind that, to apply the lonely rule, we need to check each of the 27 components to see whether it contains a lonely value, which could be any of ‘1’ through ‘9’. We’d like our solution to be both expressive and reasonably efficient.
- Digression:
- If we apply our current Solver to a puzzle, it tends to run thousands of times, because it guesses a lot. If it has guessed wrong, then any extra work we do might be wasted, unless that work happens to reduce the search space for the Solver, which will help it fail earlier. Probably we should ignore this concern until performance degrades, if it ever does.
It seems to me that it would be convenient to ask a Component something like “give me the positions and values of any lonely values in you”. I’m inclined to try to build that. But a little concern is poking me: is that what the Technique will actually want?
We’d better take a look at SudokuTechniques … and we should remember that we’ve been working on NakedPairs as a separate object. It’s getting a little chaotic around here.
SudokuTechniques is set up for lonely:
class SudokuTechniques:
only_one = 0
def __init__(self, puzzle):
self.puzzle = puzzle
def apply(self):
self.only_one_possible_value()
self.only_one_possible_position()
return self.puzzle
def only_one_possible_value(self):
changed = True
while changed:
changed = False
for cell in self.puzzle.unknown_cells():
available = self.puzzle.possible_answers(cell)
if len(available) == 1:
SudokuTechniques.only_one += 1
self.puzzle = Puzzle.evolve_with_change(self.puzzle, available[0], cell)
changed = True
def only_one_possible_position(self):
pass
only_one_possible_position is the current name for lonely. We’ll let the name ride, and see which term feels better. What would this check want to do? Let’s sketch it in, wishful-thinking style … something like this:
def only_one_possible_position(self):
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
I’m following the same format as the other method, basically looping on this rule until it finds nothing to do. It is possible that it would be better to loop as a group.
- Note
- I have been thinking that most rules adjust candidates but not puzzle results. So far, these two rules both change the puzzle. So be it, but I’ve made a note of the observation somewhere in my head. Maybe.
Neither get_all_components nor get_lonely_ones is defined. Aside from that, this might work. Let’s extend our test to actually test it.
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
all_but_7 = [row_1.candidates_at(cell) for cell in range(9) if cell != 7]
for candidate in all_but_7:
assert '1' not in candidate
assert '1' in row_1.candidates_at(7)
techniques = SudokuTechniques(puzzle)
new_puzzle = techniques.only_one_possible_position()
assert new_puzzle is not puzzle
Even before I wrote that, tests were failing, because, of course, the new method cannot run. Let’s do a little hack:
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
Now I can apply the technique from my test, but it is not yet applied in other tests. Curiously, however, my new test is passing, saying that new_puzzle is not puzzle.
Oh. My test is mistaken. Should be:
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
all_but_7 = [row_1.candidates_at(cell) for cell in range(9) if cell != 7]
for candidate in all_but_7:
assert '1' not in candidate
assert '1' in row_1.candidates_at(7)
techniques = SudokuTechniques(puzzle)
techniques.only_one_possible_position()
new_puzzle = techniques.puzzle
assert new_puzzle is not puzzle
The techniques do not return a puzzle, only the main apply call, so we must rip it untimely from the techniques instance.
Now we can set the flag, get our test to fail more interestingly:
> components = self.get_all_components()
E AttributeError: 'SudokuTechniques' object has no attribute 'get_all_components'
def get_all_components(self):
puzzle = self.puzzle
cols = [Component.column(puzzle, pos) for pos in range(9)]
rows = [Component.row(puzzle, pos) for pos in range(0, 81, 9)]
subs = [Component.sub_grid(puzzle, pos) for pos in [0, 3, 6, 27, 30, 33, 54, 57, 60]]
return cols + rows + subs
I think that’s right. I am not certain. If this isn’t right, well, that would be bad. I’ll make a note to test it. In any case the method should reside somewhere else, probably Component.
We’re failing now here:
> lonely_ones = component.get_lonely_ones()
E AttributeError: 'Component' object has no attribute 'get_lonely_ones'
Not a surprise. Let’s review the technique to see what this missing method must do:
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
I expect get_lonely_ones to return a list of pairs, value and cell position.
Let me try to write that. I am optimistic but not wildly so.
def get_lonely_ones(self):
result = []
for value in '123456789':
positions = [pos for pos in range(9) if value in self.candidates_at(pos)]
if len(positions) == 1:
result.append([value, positions[0]])
return result
My test passes! I am partly “yes, obviously” and partly “really???”.
Well, I am green, so I can commit: first lonely test passes.
Let’s write a few more tests, including some that won’t find any lonely values.
(I’m starting to think that these guys aren’t lonely—they have lots of friends—so the “only one possible place” name is probably better.) We’ll deal with that: I’m sure we have refactoring to do. For now, I want a bit more certainty and to take break while I’m ahead.
- Note
- An early break does not come to pass. C’est la vie, we old folks say.
def test_lonely_2(self):
lonely_puzzle_2 = [
'000100000',
'001000000',
'010000000',
'100000000',
'000000000',
'000000001',
'000000010',
'000000100',
'000001000'
]
puzzle = Puzzle.from_list(lonely_puzzle_2)
pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 36)
all_but_4 = [row_1.candidates_at(cell) for cell in range(9) if cell != 4]
for candidate in all_but_4:
assert '1' not in candidate
assert '1' in row_1.candidates_at(4)
techniques = SudokuTechniques(puzzle)
techniques.only_one_possible_position(True)
new_puzzle = techniques.puzzle
assert new_puzzle is not puzzle
That’s this puzzle, if it helps:
| 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |
Then I printed the result, for our edification and now I am not as happy as I was. Here are the results for both puzzles:
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | ||
| 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |
We seem to have gone hog wild here, setting ones into the whole first row … and nowhere else.
- Note
- Possibly, rolling back this second test and addressing the first would have been a better play at this point. However, there are discoveries below, without which the roll back might not have helped. I think I needed to get confused enough to drop all the balls, so that when I picked them up again my preconceptions would be mostly gone.
-
A simple break might have helped. Anyway the path continues.
Enhance both tests. Our rule should have made exactly one change in each case, at least according to my intentions:
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
all_but_7 = [row_1.candidates_at(cell) for cell in range(9) if cell != 7]
for candidate in all_but_7:
assert '1' not in candidate
assert '1' in row_1.candidates_at(7)
techniques = SudokuTechniques(puzzle)
techniques.only_one_possible_position(True)
new_puzzle = techniques.puzzle
assert new_puzzle is not puzzle
# pretty_print(new_puzzle.game)
for pos in range(81):
if pos != 7:
assert new_puzzle.cell_at(pos) == puzzle.cell_at(pos)
else:
assert new_puzzle.cell_at(pos) == '1'
That should fail at pos = 1 unless I miss my guess. The assert fails but does not tell me pos. Let’s add a message.
for pos in range(81):
if pos != 7:
v_new = new_puzzle.cell_at(pos)
v_old = puzzle.cell_at(pos)
assert v_new == v_old, f'at {pos=} found {v_new} expecting {v_old}'
else:
assert new_puzzle.cell_at(pos) == '1'
And we get an improved failure:
> assert v_new == v_old, f'at {pos=} found {v_new} expecting {v_old}'
E AssertionError: at pos=1 found 1 expecting 0
E assert '1' == '0'
Right, so what happened here, that’s the question. I’m just going to dump some info to find out.
The bug appears to be that we are reporting solved values as if they were candidates. I’m not sure yet.
- Note
- This is indeed a defect. Unfortunately for our hero, there are two defects in the code. We’ll discuss that below.
I’m going to take a wild guess here, though I might be wise to back away slowly and start over.
def candidates_at(self, position):
position - self.positions[position]
if (v := self.puzzle.cell_at(position)) != '0':
return []
else:
return self.puzzle.candidates.at(position)
That used to just return candidates_at. A good question is why that actually has a value.
I do think we’re going to need to back away, but the change above breaks another test. Why?
Ah, good, it’s the check for 2-9, which should not be passing for the cells set to 1 …
Meh! Can’t figure it out. I have interfered with the canine here somewhere but I don’t know where. Roll back to my running but not very complete test.
- Note
- None too soon, but I think confusion was important here, so that I flushed my buffers of any notions that were mistaken. Rolling back let me look at things with fresher eyes.
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
all_but_7 = [row_1.candidates_at(cell) for cell in range(9) if cell != 7]
for candidate in all_but_7:
assert '1' not in candidate
assert '1' in row_1.candidates_at(7)
techniques = SudokuTechniques(puzzle)
techniques.only_one_possible_position(True)
new_puzzle = techniques.puzzle
assert new_puzzle is not puzzle
I think this test is too weak. Yes, the puzzle changed. But it changed too much and the test did not detect that key fact. The code:
class SudokuTechniques:
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
class Component:
def get_lonely_ones(self):
result = []
for value in '123456789':
positions = [pos for pos in range(9) if value in self.candidates_at(pos)]
if len(positions) == 1:
result.append([value, positions[0]])
return result
Let’s see what is happening, first via a print.
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
if len(lonely_ones) > 0:
print(f'{component.positions=}')
print(f'{lonely_ones=}')
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
That’s printing things like this:
component.positions=[0, 9, 18, 27, 36, 45, 54, 63, 72]
lonely_ones=[['1', 0]]
component.positions=[3, 12, 21, 30, 39, 48, 57, 66, 75]
lonely_ones=[['1', 2]]
component.positions=[6, 15, 24, 33, 42, 51, 60, 69, 78]
lonely_ones=[['1', 3]]
So we see that it is finding candidates where there should be none. I think that bug is in candidates_at, as I mentioned above. Oh! And I notice something else, the lonely_ones pair has not the cell, but the position in the component. Fix that first.
Note: This is the second bug. I already know the first one. This one just sprang out at me as I read the code. A test at the right level would have found it. At this writing, I do not know what that test should have been, but it’s clear that we would have been better off had we had it.
def get_lonely_ones(self):
result = []
for value in '123456789':
positions = [pos for pos in range(9) if value in self.candidates_at(pos)]
if len(positions) == 1:
result.append([value, self.positions[positions[0]]])
return result
We really need to get better naming for positions, indices, and cells. But now those prints should make more sense, albeit they’re still wrong:
component.positions=[0, 9, 18, 27, 36, 45, 54, 63, 72]
lonely_ones=[['1', 0]]
component.positions=[3, 12, 21, 30, 39, 48, 57, 66, 75]
lonely_ones=[['1', 21]]
component.positions=[6, 15, 24, 33, 42, 51, 60, 69, 78]
lonely_ones=[['1', 33]]
Now the other bug, I claim, is that we should be returning the empty list for candidates of a cell that is solved. Thus:
def candidates_at(self, position):
puzzle_cell_index = self.positions[position]
if self.puzzle.cell_at(puzzle_cell_index) != '0':
return []
else:
return self.puzzle.candidates.at(puzzle_cell_index)
Now let’s see what’s going on with our test. I am quite hopeful now.
component.positions=[7, 16, 25, 34, 43, 52, 61, 70, 79]
lonely_ones=[['1', 16]]
component.positions=[9, 10, 11, 12, 13, 14, 15, 16, 17]
lonely_ones=[['1', 16]]
component.positions=[6, 7, 8, 15, 16, 17, 24, 25, 26]
lonely_ones=[['1', 16]]
The cell to be set is indeed number 16, 9 + 7, as it is #7, starting from zero, in the 1th row, starting from 0. We have found it “lonely” in all three components that contain it. I wonder, though, why we didn’t put it into the solution and then not find it the second and third time through. But is the answer now correct? Let’s check that before we forget.
def test_lonely(self):
lonely_puzzle = [
'100000000',
'000000000',
'000100000',
'000000100',
'000000000',
'000000000',
'000000000',
'000000000',
'000000001'
]
puzzle = Puzzle.from_list(lonely_puzzle)
# pretty_print(puzzle.game)
row_1 = Component.row(puzzle, 9)
all_but_7 = [row_1.candidates_at(cell) for cell in range(9) if cell != 7]
for candidate in all_but_7:
assert '1' not in candidate
assert '1' in row_1.candidates_at(7)
techniques = SudokuTechniques(puzzle)
techniques.only_one_possible_position(True)
new_puzzle = techniques.puzzle
assert new_puzzle is not puzzle
for cell in range(81):
if cell != 16:
assert new_puzzle.cell_at(cell) == puzzle.cell_at(cell)
else:
assert new_puzzle.cell_at(cell) == '1'
That is passing, telling me that the new_puzzle has just the one additional solved cell. I trust no one, so let’s print it:
| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | ||
| 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
Yes, there in row number 1 (second row for us 1 counters), second to last value is ‘1’ as intended.
But did we solve it three times?
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
if len(lonely_ones) > 0:
print(f'{component.positions=}')
print(f'{lonely_ones=}')
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
We surely did. We won’t refresh components until the full loop over components exits.
It’s interesting that all three components, the row, column, and sub-grid conclude that ‘1’ can only go there: I would not have guessed that. A moment’s thought tells us that the candidates in each cell take into account all the rows and columns and sub-grids that impact that cell so of course it’s the same situation on any of the three components. Let’s see if we can make it happen just once.
The thing is, we are looping over all components, be they rows, columns, or sub-grids, in an order that isn’t random but is certainly arbitrary. So we can’t just break the loop, unless we are willing to compute all the components again. Well, that will only happen if we’ve found a solution, so it should be OK. Let’s break. Remind me to write a test with more than one lonely value.
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
# if len(lonely_ones) > 0:
# print(f'{component.positions=}')
# print(f'{lonely_ones=}')
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
if changed:
break
That’s too ugly to be allowed to live. Let’s do some extraction.
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
changed = self.process_lonely_ones(changed, component)
if changed:
break
def process_lonely_ones(self, changed, component):
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
return changed
No, I hate that for passing the boolean into process. Undo that, back to this:
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
Now try this:
def only_one_possible_position(self, run=False):
if not run:
return
changed = True
while changed:
changed = self.fixed_a_lonely_one(changed)
def fixed_a_lonely_one(self, changed):
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
return changed
Let’s follow this a bit further …
def only_one_possible_position(self, run=False):
if not run:
return
while self.fixed_a_lonely_one():
pass
def fixed_a_lonely_one(self):
changed = False
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
changed = True
return changed
That while looks odd. Is there a better way to express that? I don’t know. Anyway, we can do a bit better:
def only_one_possible_position(self, run=False):
if not run:
return
while self.fixed_a_lonely_one():
pass
def fixed_a_lonely_one(self):
components = self.get_all_components()
for component in components:
lonely_ones = component.get_lonely_ones()
for lonely_value, lonely_cell in lonely_ones:
self.puzzle = Puzzle.evolve_with_change(self.puzzle, lonely_value, lonely_cell)
return True
return False
Now we just create one new puzzle, even if there are a few lonely_ones pairs (and we think there always will be). I am reasonably confident in this, but want more tests. However, I am officially tired at this point, and my eye feels like there is a bird in it, so let’s sum up. The article is immense anyway.
Commit: Test actually working for lonely.
Summary
Our first implementation was basically correct but with two defects:
- The candidates for a solved cell were coming back as if it were unsolved instead of as an empty list;
- The pair returned for a lonely value was the value and the position in the component we were inspecting, instead of its position in the puzzle.
- I promised to discuss this:
-
The first lesson to learn is this: if at any point in time, there are two defects in the code, and only one test failing, it is quite likely that we should be taking, and testing, smaller tests. Why? Because when our steps are properly small, we rarely ever get two defects in, because our tiny steps and tests find the first one before we have time to put in the second one.
-
We conclude that I may have been—probably was—taking steps that were a bit larger than ideal.
The fact that there were two defects meant that when my first “fix” failed, I fairly quickly gave up, rolled back my thinking (but not the code) and did better testing and thinking. That time through, I managed to find both defects. My tests helped, and some judicious prints helped as well.
I find that printing things helps me more often than might be ideal. If my tests were better, they would find the issues without my needing to print out things. Let’s face it, printing stuff is just one inch away from stepping through the program. I prefer for my tests to find things.
Why is that?
Why is that, you might ask. Well, it’s because when we find a problem by writing a test, we form a hypothesis about an assertion that should pass but won’t, we add that assertion, and, when it fails, we can increase our belief in the hypothesis. It’s a thoughtful process. Debugging or printing is more, well, confused. I don’t know what’s going on, let’s just dump core and blunder around.
Often, it’s hard to write a test and easy to write a print or set a breakpoint. A fanatic would say that the tests should be easier to write, and yeah, I get their point, but if the fastest way to a solution is to print something and look, then that’s what we should do, isn’t it?
Well, if it’s just this once, yes, it’s the thing to do. But if it would take fifteen minutes to improve our ability to test, and five minutes per print / reading / debugging session, fixing the tests is better if we’re going to have three or more sessions of print debugging.
Same with rolling back. In this case I just rolled back my tests and prints, though more often we’ll roll back a failed solution and begin again. I always feel like just give me a few more minutes and I can debug this, but I’ve trained myself to hear the desperation in my inner voice and use it to signal, no, better roll back. Rolling back, I feel sure, is never slower than debugging, unless I am very deep in a session with no recent commits. When I’m committing frequently, rolling back is almost worth it even for a typo. Almost.
In any case, I am in no danger of rolling back too often.
Anyway … the lonely technique seems to work, and I want more and deeper tests, including some where the technique can be applied more than once. I’m confident but not confident enough.
Along the way, I have encountered yet again the issue of position, cell index, component element number, a whole raft of subscripts that can be confusing. I think—but am not certain—that an object like Component should behave more like a collection of actual cells in the puzzle, and should not have an index of its own. Currently it is indexed 0-8 and contains a list of positions that contain the cell indices of the component in the puzzle. I’ve made a mistake today, mixing up the two notions, and today isn’t the first time.
“The objects aren’t helping us enough.” We will explore that in the near future. I note that Component does implement __iter__ but I’m not at all sure what it’s good for. Perhaps it should be good for something. Perhaps there should be a Cell class containing a value and a candidates list, and secretly knowing a position in the puzzle to which it refers. As of today, I just say position or cell more or less at random and that is not working as well for me as it might.
Nonetheless, I think we have our technique working. We’ll look at verifying that, folding it into the Techniques fully, and making the code better, in upcoming sessions.
The session of the day is sufficient thereto. See you next time!