def make_satisfactory(puzzle): """Add all clues that seem to require guessing; return amount added. Parameters ---------- puzzle : Board instance The puzzle to make satisfactory by adding in non-deducible moves. Returns ------- int The difference between the puzzle's new clue count and its old. See Also -------- minimize : a method that makes puzzles harder by removing clues. """ clues_added = 0 solver = Solver(puzzle.duplicate()) solver.autosolve() for move in solver.guessed_moves(): puzzle.set_cell(*move) clues_added += 1 return clues_added
def test_make_rotationally_symmetric(self): for seed in self.MINIMIZABLE_SEEDS: puzzle = generator.generate(seed) prev_clue_count = 0 for minimized in [False, True]: iter_info = 'for seed={} and minimized={}'.format(seed, minimized) symmetric_puzzle = puzzle.duplicate() # Test that the reported clue difference is correct clue_difference = generator.make_rotationally_symmetric(symmetric_puzzle, minimized=minimized) expected_clue_difference = symmetric_puzzle.clue_count() - puzzle.clue_count() assertion_msg = '{} != {} {}'.format(clue_difference, expected_clue_difference, iter_info) self.assertEqual(clue_difference, expected_clue_difference, assertion_msg) clue_count = symmetric_puzzle.clue_count() if prev_clue_count: # Test that the minimized puzzle has no more clues than # the symmetric one (NB `MINIMIZABLE_SEEDS` are not # necessarily minimizable while also maintaining # symmetry) assertion_msg = '{} > {} {}'.format(clue_count, prev_clue_count, iter_info) self.assertLessEqual(clue_count, prev_clue_count, assertion_msg) prev_clue_count = 0 prev_clue_count = 0 if minimized else clue_count # Test that the puzzle is actually symmetric rows = list(map(tuple, np.array(self._binarize_rows(symmetric_puzzle.rows())))) rot_rows = list(map(tuple, np.rot90(rows, k=-2))) assertion_msg = '{} != {} {}'.format(rows, rot_rows, iter_info) self.assertEqual(rows, rot_rows, assertion_msg) puzzle = generator.generate(self.NONSATISFACTORY_SEEDS[0], minimized=True) satisfactory_puzzle = puzzle.duplicate() clue_difference = generator.make_satisfactory(satisfactory_puzzle) puzzle_differences = puzzle.differences(satisfactory_puzzle) # Test that clues were added self.assertGreater(len(puzzle_differences), 0) # Make puzzle symmetric while keeping it satisfactory generator.make_rotationally_symmetric(satisfactory_puzzle, minimized=True, keep_satisfactory=True) # Test that the puzzle is now actually symmetric rows = list(map(tuple, np.array(self._binarize_rows(satisfactory_puzzle.rows())))) rot_rows = list(map(tuple, np.rot90(rows, k=-2))) self.assertEqual(rows, rot_rows) # Test that the puzzle is still satisfactory solver = Solver(satisfactory_puzzle) solver.autosolve() self.assertFalse(len(solver.guessed_moves()))
def solved_puzzle(seed): """Return a random, solved puzzle generated from the provided seed. Parameters ---------- seed : int, long, or hashable The seed to use for generating the solved board. Returns ------- Board instance The solved puzzle generated from the given seed. """ # Decreasing this allows for more randomness but can also increase the # time required to generate from some seeds min_start_clues = 3 random.seed(seed) target_row = random.choice(Board.SUDOKU_ROWS) columns = Board.SUDOKU_COLS[:] random.shuffle(columns) # `numpy.random` excludes upper limit; builtin `random` includes it upper_limit_adjustment = 1 if using_numpy_random() else 0 column_count = random.randint(min_start_clues, len(columns) + upper_limit_adjustment) # Initialize a blank board puzzle = Board(name=str(seed)) # To get the solver started and insert randomness for i, target_col in enumerate(columns[:column_count]): target_number = Board.SUDOKU_NUMBERS[i] puzzle.set_cell(target_number, target_row, target_col) solver = Solver(puzzle) solver.autosolve_without_history() return puzzle
def test_make_satisfactory(self): # Test that the return value is 0 when nothing can be added solved_puzzle = generator.solved_puzzle(self._random_seed()) clues_added = generator.make_satisfactory(solved_puzzle) self.assertFalse(clues_added) for seed in self.NONSATISFACTORY_SEEDS: original_puzzle = generator.generate(seed, minimized=True) satisfactory_puzzle = original_puzzle.duplicate() # Test that it added clues clues_added = generator.make_satisfactory(satisfactory_puzzle) self.assertGreater(clues_added, 0) # Test that the reported number of clues added is accurate clue_difference = satisfactory_puzzle.clue_count() - original_puzzle.clue_count() self.assertEqual(clues_added, clue_difference) # Test that it actually eliminated guesses solver = Solver(satisfactory_puzzle) solver.autosolve() self.assertFalse(len(solver.guessed_moves()))
def test_explain(self): # So the toggles below work as expected self.assertFalse(self.options.markview) # Set up main controller command_queue = [] temp_solver = Solver(self.puzzle.duplicate()) self.assertTrue(temp_solver.autosolve()) command_queue.append('# Test `explain`ing the initial board') command_queue.append(self.EXPLAIN_CMD) command_queue.append( '# Make explain run automatically after each step') command_queue.append('set explainsteps') seen_move_types = set() command_queue.append( '# Test `explain`ing every `step` and `stepm` until solved') for num, row, col, _, move_type in temp_solver.move_history: new_move_type = move_type not in seen_move_types if new_move_type: command_queue.append( '# Make sure this type of move displays properly in markview' ) command_queue.append('set markview # turn markview on') command_queue.append('step') # +1 to correct zero-indexed row, col from `temp_solver` command_queue.append('stepm {} {} {}'.format( row + 1, col + 1, num)) if new_move_type: command_queue.append('set markview # turn markview off') seen_move_types.add(move_type) command_queue.append('quit') # Set up second controller for testing fringe cases fringe_puzzle = Board(name='blank board') fringe_command_queue = ['# Test some fringe cases'] fringe_command_queue.append('# Try to explain unreasonable move') fringe_command_queue.append('stepm 5 5 9') fringe_command_queue.append('{} # No reason for move'.format( self.EXPLAIN_CMD)) fringe_command_queue.append( '# Try to explain step that removes a clue') fringe_command_queue.append('stepm 5 5 0') fringe_command_queue.append('{} # Just reprint board'.format( self.EXPLAIN_CMD)) fringe_command_queue.append('quit') # Run commands controller = SolverController(self.puzzle, command_queue=command_queue, options=self.options) fringe_controller = SolverController( fringe_puzzle, command_queue=fringe_command_queue, options=self.options) self.redirect_output() controller.solve() # Turn off option set by previous controller fringe_controller.options.explainsteps = False fringe_controller.solve() self.reset_output() if self.compare_file is not None: self.output_file.seek(0) output_lines = self.output_file.read().splitlines() compare_lines = self.compare_file.read().splitlines() self.assertEqual(output_lines, compare_lines)
def test_step(self): command_queue = [] available_first_moves = [] solver = Solver(self.puzzle) for (row, col) in Board.SUDOKU_CELLS: candidates = solver.candidates(row, col) if len(candidates) == 1 and self.puzzle.get_cell( row, col) == Board.BLANK: # Solver uses zero-indexed locations, so correct available_first_moves.append( (row + 1, col + 1, candidates.pop())) command_queue.append('# test `step` variants') for i, (row, col, num) in enumerate(available_first_moves[:2]): command_queue.append('# try to deduce {} at ({}, {})'.format( num, row, col)) box, _ = Board.box_containing_cell(row - 1, col - 1) box += 1 # Alternate the command variation used each iteration command_queue.append('{} {}'.format(self.STEPR_CMDS[i % 2], row)) command_queue.append(self.UNSTEP_CMD) command_queue.append('{} {}'.format(self.STEPC_CMDS[i % 2], col)) command_queue.append(self.UNSTEP_CMD) command_queue.append('{} {}'.format(self.STEPB_CMDS[i % 2], box)) command_queue.append(self.UNSTEP_CMD) command_queue.append('# set {} at ({}, {}) manually'.format( num, row, col)) command_queue.append('{} {} {} {}'.format(self.STEPM_CMDS[i % 2], row, col, num)) command_queue.append(self.UNSTEP_CMD) command_queue.append('# using `stepm` ROW COL NUM alias') command_queue.append('{1}{0}{2}{0}{3}'.format( ' ' * (i % 2), row, col, num)) command_queue.append(self.UNSTEP_CMD) command_queue.append('# test breaking on `step`s') for step_cmd, row, col in self.FIRST_MOVES: command_queue.append('{} {} {}'.format(self.BREAK_CMD, row, col)) command_queue.append(step_cmd) command_queue.append('# breakpoint at ({}, {})'.format(row, col)) command_queue.append(self.UNSTEP_CMD) command_queue.append(self.DELETE_CMD) command_queue.append('# test repeating `step`s') command_queue.append('{} 2'.format(self.STEP_CMDS[0])) command_queue.append('{} 12'.format(self.STEPR_CMDS[0])) command_queue.append('{} 12'.format(self.STEPC_CMDS[0])) command_queue.append('{} 12'.format(self.STEPB_CMDS[0])) command_queue.append('{} 8'.format(self.UNSTEP_CMD)) command_queue.append( '# test passing bad arguments to various commands') command_queue.append('# No steps left to undo') command_queue.append('{} 82'.format(self.UNSTEP_CMD)) command_queue.append('# Must be integer') command_queue.append('{} x'.format(self.STEP_CMDS[0])) command_queue.append('{} x'.format(self.UNSTEP_CMD)) command_queue.append('{} 1 x'.format(self.STEPR_CMDS[0])) command_queue.append('{} 1 x'.format(self.STEPC_CMDS[0])) command_queue.append('{} 1 x'.format(self.STEPB_CMDS[0])) command_queue.append('{} x x x'.format(self.STEPM_CMDS[0])) command_queue.append('# Invalid row') command_queue.append('{} 0 1 1'.format(self.STEPM_CMDS[0])) command_queue.append('{} 0'.format(self.STEPR_CMDS[0])) command_queue.append('# Invalid column') command_queue.append('{} 1 0 1'.format(self.STEPM_CMDS[0])) command_queue.append('{} 0'.format(self.STEPC_CMDS[0])) command_queue.append('# Invalid box') command_queue.append('{} 0'.format(self.STEPB_CMDS[0])) command_queue.append('# Argument required') command_queue.append(self.STEPR_CMDS[0]) command_queue.append(self.STEPC_CMDS[0]) command_queue.append(self.STEPB_CMDS[0]) command_queue.append('# Exactly three arguments required') command_queue.append('{}'.format(self.STEPM_CMDS[0])) command_queue.append('{} 1'.format(self.STEPM_CMDS[0])) command_queue.append('{} 1 1'.format(self.STEPM_CMDS[0])) command_queue.append('# Inconsistent move') command_queue.append(self.INCONSISTENT_MOVE) # Tell controller to exit command_queue.append('quit') # Run commands controller = SolverController(self.puzzle, command_queue=command_queue, options=self.options) self.redirect_output() controller.solve() self.reset_output() if self.compare_file is None: return self.output_file.seek(0) output_lines = self.output_file.read().splitlines() compare_lines = self.compare_file.read().splitlines() self.assertEqual(output_lines, compare_lines)
def make_rotationally_symmetric(puzzle, minimized=False, keep_satisfactory=False): """Give puzzle 180-deg rotational symmetry; return clue count change. Add and, if `minimized` is True, remove clues from `puzzle` such that, if this new puzzle were rotated 180 degrees, the cells with clues in them in the rotated and non-rotated puzzles would be the same. Parameters ---------- puzzle : Board instance The puzzle to make symmetric by adding or removing clues. minimized : bool, optional True if clues may be removed from the puzzle provided this can be done while preserving symmetry and properness, and False if symmetry may only be created by adding clues (default False). keep_satisfactory : bool, optional Used when `minimized` is True to determine if clues whose removal introduce guessing may still be removed (`keep_satisfactory=True`) or if that should be prevented (`keep_satisfactory=False`) (default False). Returns ------- int The difference between the puzzle's new clue count and its old. """ clues = puzzle.clues() original_clue_count = len(clues) original_guess_count = 0 if keep_satisfactory: temp_solver = Solver(puzzle.duplicate()) temp_solver.autosolve() original_guess_count = len(temp_solver.guessed_moves()) solver = Solver(puzzle.duplicate()) solver.autosolve_without_history() for (num, row, col) in clues: rot_row, rot_col = _rotated_location(row, col) rot_num = solver.puzzle.get_cell(rot_row, rot_col) if not minimized: puzzle.set_cell(rot_num, rot_row, rot_col) continue # See if the puzzle still has a unique solution with the clues at # the original location and its rotational partner location removed puzzle.set_cell(Board.BLANK, row, col) puzzle.set_cell(Board.BLANK, rot_row, rot_col) temp_solver = Solver(puzzle.duplicate()) if temp_solver.solution_count(limit=2) != 1 or keep_satisfactory: new_guess_count = 0 if keep_satisfactory: # Check if changes introduced additional guesses temp_solver.autosolve() new_guess_count = len(temp_solver.guessed_moves()) if not keep_satisfactory or new_guess_count > original_guess_count: # Puzzle no longer has a unique solution or now has more # guesses than before; undo changes puzzle.set_cell(num, row, col) puzzle.set_cell(rot_num, rot_row, rot_col) return puzzle.clue_count() - original_clue_count
def minimize(puzzle, threshold=17): """Remove clues not needed for a unique solution; return amount removed. Remove all clues from `puzzle` that are not needed for it to have a single solution (provided it's initial clue count is above `threshold`). Parameters ---------- puzzle : Board instance The puzzle from which to remove unnecessary clues. threshold : int, optional Puzzles with their number of clues less than or equal to this value will not be minimized further (default 17). Returns ------- int The difference between the puzzle's new clue count and its old. See Also -------- make_satisfactory : a method that makes puzzles easier by adding clues. Notes ----- The default for `threshold` is 17 because that's the minimum number of clues needed to guarantee a unique solution to the puzzle.[1]_ This function does not check that the minimized puzzle has the same solution as the original one, but that outcome should be inevitable: the function only ever removes clues, and removing a clue from a Sudoku will never remove a solution from the solution set; at most, doing so adds one or more new solutions, and since the function undoes any removals that increase the size of the solution set, the solution sets of the original and the minimized puzzle have to be the same. References ---------- .. [1] G. McGuire, B. Tugemann and G. Civario, "There Is No 16-Clue Sudoku: Solving the Sudoku Minimum Number of Clues Problem via Hitting Set Enumeration", Experimental Mathematics, vol. 23, no. 2, pp. 190-217, 2012. Available at: https://arxiv.org/abs/1201.0749 [Accessed 25 Jun. 2017]. """ original_clue_count = puzzle.clue_count() if threshold < 17 or original_clue_count <= threshold: return 0 solver = Solver(puzzle) while True: clues_removed = 0 for (num, row, col) in puzzle.clues(): puzzle.set_cell(Board.BLANK, row, col) if solver.solution_count(limit=2) != 1: # Multiple solutions after this change, so reset puzzle.set_cell(num, row, col) else: clues_removed += 1 if not clues_removed: break return puzzle.clue_count() - original_clue_count
def main(): """Get puzzle(s) from file, user, or seed and solve each in turn. """ args = _get_parser().parse_args() # Warn if the UTF-8 output will look like garbage on this terminal if not args.ascii and sys.stdout.encoding in ['ascii', 'ANSI_X3.4-1968']: error.error( 'assuming --ascii since your terminal does not seem to' ' support UTF-8 output. To fix permanently, please set' " your locale environment variables and your terminal's" ' character encoding settings appropriately. To force' ' UTF-8 output temporarily, try calling this program' ' using "PYTHONIOENCODING=utf_8 sudb" instead of just' ' "sudb".\n', prelude='warning') args.ascii = True init_commands = [] if not args.no_init: init_path = os.path.expanduser(INIT_FILE) try: with open(init_path, 'r') as init: init_commands = [ line for line in init.read().split('\n') if line ] except IOError: pass command_queue = [] if args.execute is None else args.execute # Flatten the list of puzzle lines lines = None if args.lines is None else [ line for line_list in args.lines for line in line_list ] if args.random is not None and not args.random: # `-r` was used without arguments args.random.append(generator.random_seed()) log = PuzzleErrorLogger() puzzles = importer.get_puzzles(filenames=args.file, lines=lines, seeds=args.random, logger=log) solved_puzzles = 0 if log.error_count() > 0: # There was an import error; print a newline to stderr for spacing error.error('', prelude='') print('{} puzzle{}to solve.'.format(len(puzzles), 's ' if len(puzzles) != 1 else ' ')) for i, puzzle in enumerate(puzzles): _edit_puzzle(puzzle, args.satisfactory, args.minimized, args.symmetrical) skip_solving = False if log.autolog(puzzle, report=True) and log.in_mask( puzzle, log.unsolvable_mask()): # A unsolvable error occured skip_solving = True # A copy of the original is needed to find differences between it # and the final state of the board and for error output original_puzzle = puzzle.duplicate() if skip_solving: pass elif args.auto: auto_solver = Solver(puzzle) if auto_solver.autosolve_without_history(): solved_puzzles += 1 else: options = SolverController.Options() options.ascii = args.ascii solver = SolverController(puzzle, init_commands=init_commands, command_queue=command_queue, options=options) try: if solver.solve(): solved_puzzles += 1 except BaseException: # To make it easier to return to board state at crash print('\nFinal State of Puzzle {} ({}):'.format( i + 1, puzzle.name)) print(puzzle) print() with open(CMDHIST_FILE, 'w+') as cmdhist: cmdhist.write('\n'.join(solver.command_history)) puzzle_line_args = ' '.join(str(original_puzzle).split()) print('Command history saved in "{}". To restore:'.format( CMDHIST_FILE)) print('{} -x "source {}" -l {}'.format(sys.argv[0], CMDHIST_FILE, puzzle_line_args)) print() raise colormap = None if args.difference: colormap = frmt.get_colormap(puzzle.differences(original_puzzle), frmt.Color.GREEN) puzzle_str = frmt.strfboard(puzzle, colormap=colormap, ascii_mode=args.ascii) print('\nEnd Board for Puzzle {}:'.format(i + 1)) print('{}\n({})\n'.format(puzzle_str, puzzle.name)) print('Solved {} of {}.'.format(solved_puzzles, len(puzzles))) if log.error_count() > 0: print() log.print_summary()
def test_set(self): temp_solver = Solver(self.puzzle.duplicate()) move1_row, move1_col = temp_solver.step() move1_box, _ = Board.box_containing_cell(move1_row, move1_col) move1_num = temp_solver.puzzle.get_cell(move1_row, move1_col) # Adjust zero-indexed locations returned by `temp_solver` move1_row += 1 move1_col += 1 move1_box += 1 command_queue = [] command_queue.append('# test `set` with no subcommand') command_queue.append(self.SET_CMD) command_queue.append('# test `set ascii`') command_queue.append('{} ascii # turn on'.format(self.SET_CMD)) command_queue.append('print') command_queue.append('{} ascii # turn off'.format(self.SET_CMD)) command_queue.append('print') command_queue.append('# test `set guessbreak`') command_queue.append( 'step {} # right before guess'.format(self.MOVE_OF_FIRST_GUESS - 1)) command_queue.append('check move_before_guess') command_queue.append('step 2') command_queue.append('restart move_before_guess') command_queue.append('{} guessbreak # turn on'.format(self.SET_CMD)) command_queue.append('step 2') command_queue.append('restart') command_queue.append('{} guessbreak # turn off'.format(self.SET_CMD)) command_queue.append('# test `set explainsteps`') command_queue.append('{} explainsteps # turn on'.format(self.SET_CMD)) command_queue.append( '# make sure explanation is printed for `step`/`stepm`') command_queue.append('step') command_queue.append('stepm {} {} {}'.format(move1_row, move1_col, move1_num)) command_queue.append('unstep 2') command_queue.append( '# make sure explanation is printed for other `step` variants') command_queue.append('stepr {}'.format(move1_row)) command_queue.append('unstep') command_queue.append('stepc {}'.format(move1_col)) command_queue.append('unstep') command_queue.append('stepb {}'.format(move1_box)) command_queue.append('unstep') command_queue.append('# make sure explanation is printed for `finish`') command_queue.append('break {} {} # avoid excess output'.format( move1_row, move1_col)) command_queue.append('finish') command_queue.append('{} explainsteps # turn off'.format(self.SET_CMD)) command_queue.append('# test `set markview`') command_queue.append('{} markview # turn on'.format(self.SET_CMD)) command_queue.append('mark 1 1 9 # add some candidates') command_queue.append('mark 9 9 9') command_queue.append('step') command_queue.append('explain') command_queue.append('unstep') command_queue.append('{} markview # turn off'.format(self.SET_CMD)) command_queue.append('# test `set prompt`') command_queue.append('set prompt sudb# ') command_queue.append('set prompt all spaces taken literally> ') command_queue.append('# even no argument is taken literally') command_queue.append('set prompt') command_queue.append('# reset prompt') command_queue.append('set prompt {}'.format(self.options.prompt)) command_queue.append('# test `set width`') command_queue.append('{} width 80 # set wide'.format(self.SET_CMD)) command_queue.append('print marks # show wide puzzle') command_queue.append('help step # show wide wrapped text') command_queue.append('{} width 60 # set narrow'.format(self.SET_CMD)) command_queue.append('print marks # show narrow puzzle') command_queue.append('{} ascii'.format(self.SET_CMD)) command_queue.append('print marks # show narrow ascii puzzle') command_queue.append('help step # show narrow wrapped text') command_queue.append('{} width 0 # restore default'.format( self.SET_CMD)) command_queue.append('# test passing bad arguments') command_queue.append('# Undefined set command') command_queue.append('{} madeup_set_command'.format(self.SET_CMD)) command_queue.append('# Argument must be integer') command_queue.append('{} width x'.format(self.SET_CMD)) command_queue.append('# Integer out of range') command_queue.append('{} width -1'.format(self.SET_CMD)) command_queue.append('# One argument required') command_queue.append('{} width'.format(self.SET_CMD)) command_queue.append('# test showing all current settings') command_queue.append('info {} # before changes'.format(self.SET_CMD)) command_queue.append('# make changes') command_queue.append('{} ascii'.format(self.SET_CMD)) command_queue.append('{} explainsteps'.format(self.SET_CMD)) command_queue.append('{} guessbreak'.format(self.SET_CMD)) command_queue.append('{} markview'.format(self.SET_CMD)) command_queue.append('{} prompt (test) '.format(self.SET_CMD)) command_queue.append('{} width 70'.format(self.SET_CMD)) command_queue.append('info {} # after changes'.format(self.SET_CMD)) # Tell controller to exit command_queue.append('quit') # Run commands controller = SolverController(self.puzzle, command_queue=command_queue, options=self.options) self.redirect_output() controller.solve() self.reset_output() if self.compare_file is None: return self.output_file.seek(0) output_lines = self.output_file.read().splitlines() compare_lines = self.compare_file.read().splitlines() self.assertEqual(output_lines, compare_lines)