feat: implement deductive Sudoku solver

Add a command-line Sudoku solver that reads a whitespace-separated puzzle file,
uses zeroes as empty cells, and prints a solved grid. The parser accepts any
square Sudoku size whose block shape is also square, including 9x9 with 3x3
blocks and 16x16 with 4x4 blocks.

The solver represents candidate sets as u128 bit masks and precomputes row,
column, block, and peer relationships for each cell. It repeatedly applies
human-style deterministic deductions by placing naked singles and hidden singles.
When those deductions stall, it falls back to a constrained trial search: choose
the unsolved cell with the fewest candidates, try high-impact values first, and
resume deduction after each assumption.

The public library API keeps parsing, solving, and formatting testable outside
the CLI. The README documents usage and the top-down project decomposition so
future changes have an architectural map.

Known limitation: the solver returns one valid completion for puzzles with
multiple solutions; it does not currently prove uniqueness.

Test Plan:
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo test
- cargo +nightly fmt
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo run -- /tmp/sudoku-ai-smoke.txt
- timeout 5 cargo run -- /tmp/sudoku-ai-underconstrained.txt

Refs: none
This commit is contained in:
2026-04-25 20:57:44 +02:00
parent 87a53c09bc
commit 06918ea670
3 changed files with 738 additions and 1 deletions
+50
View File
@@ -0,0 +1,50 @@
# sudoku-ai
`sudoku-ai` solves plain-text Sudoku files from the command line:
```sh
./sudoku-ai <sudoku_file>
```
Input is a whitespace-separated grid. Use `0` for empty cells and decimal
values for filled cells:
```text
0 2 3 0 0 0 4 5 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
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
```
The grid width must equal the grid height, and the size must have square
blocks. That supports layouts such as 4x4 with 2x2 blocks, 9x9 with 3x3
blocks, and 16x16 with 4x4 blocks.
## Project Decomposition
- Command-line interface: `src/main.rs`
- Parses the `./sudoku-ai <sudoku_file>` argument shape.
- Reads puzzle text from disk.
- Prints the solved grid or a user-facing error.
- Solver library: `src/lib.rs`
- Plain-text parser
- Ignores blank lines.
- Validates square dimensions, block shape, and value ranges.
- Constraint model
- Stores each candidate set in a `u128` bit mask.
- Precomputes row, column, and block units for each cell.
- Precomputes peer cells for fast candidate elimination.
- Deduction engine
- Propagates placed values to all peers.
- Fills naked singles when a cell has one candidate.
- Fills hidden singles when a unit has one possible place for a value.
- Search engine
- Runs deduction until it stalls.
- Chooses the unsolved cell with the fewest candidates.
- Tries high-impact candidates first, then resumes deduction after each
assumption.
+625
View File
@@ -0,0 +1,625 @@
use std::{error::Error, fmt};
type Mask = u128;
/// Solves a Sudoku puzzle from plain whitespace-separated text.
///
/// Empty cells must be written as `0`. Filled cells use decimal values from
/// `1` through the puzzle size, so a 16x16 puzzle uses `1` through `16`.
///
/// # Errors
///
/// Returns an error if the text is malformed, the size is unsupported, the
/// givens contradict Sudoku rules, or no solution can be found.
pub fn solve_text(input: &str) -> Result<SolvedGrid, SudokuError> {
let puzzle = Puzzle::parse(input)?;
let solver = Solver::new(puzzle.size, puzzle.block_side);
solver.solve(&puzzle)
}
/// A solved Sudoku grid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolvedGrid {
size: usize,
values: Vec<usize>,
}
impl SolvedGrid {
/// Formats the solved grid as plain rows of decimal numbers.
#[must_use]
pub fn format_plain(&self) -> String {
let width = self.size.to_string().len();
let mut output = String::new();
for row in 0..self.size {
if row > 0 {
output.push('\n');
}
for column in 0..self.size {
if column > 0 {
output.push(' ');
}
push_padded_number(
&mut output,
self.values[cell_index(self.size, row, column)],
width,
);
}
}
output
}
/// Returns the solved value at the given zero-based row and column.
#[must_use]
pub fn value_at(&self, row: usize, column: usize) -> Option<usize> {
(row < self.size && column < self.size)
.then(|| self.values[cell_index(self.size, row, column)])
}
/// Returns the width and height of the solved grid.
#[must_use]
pub const fn size(&self) -> usize {
self.size
}
}
/// Errors returned while parsing or solving a Sudoku puzzle.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SudokuError {
/// The puzzle text could not be parsed into a square Sudoku grid.
Parse(String),
/// The puzzle is structurally valid text, but breaks Sudoku constraints.
Invalid(String),
/// The puzzle has no solution under Sudoku constraints.
NoSolution,
}
impl fmt::Display for SudokuError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parse(message) => write!(formatter, "parse error: {message}"),
Self::Invalid(message) => write!(formatter, "invalid puzzle: {message}"),
Self::NoSolution => write!(formatter, "no solution found"),
}
}
}
impl Error for SudokuError {}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Puzzle {
size: usize,
block_side: usize,
cells: Vec<Option<usize>>,
}
impl Puzzle {
fn parse(input: &str) -> Result<Self, SudokuError> {
let rows = parse_rows(input)?;
let size = rows.len();
if size == 0 {
return Err(SudokuError::Parse(String::from("the puzzle is empty")));
}
let Some(block_side) = block_side_for(size) else {
return Err(SudokuError::Parse(format!(
"grid size {size} is not a square block layout"
)));
};
if size > Mask::BITS as usize {
return Err(SudokuError::Parse(format!(
"grid size {size} is too large; at most {} symbols are supported",
Mask::BITS
)));
}
let mut cells = Vec::with_capacity(size * size);
for (row_index, row) in rows.into_iter().enumerate() {
if row.len() != size {
return Err(SudokuError::Parse(format!(
"row {} has {} values, but the grid has {size} rows",
row_index + 1,
row.len()
)));
}
for value in row {
if value > size {
return Err(SudokuError::Parse(format!(
"value {value} is outside the allowed range 0..={size}"
)));
}
cells.push((value != 0).then_some(value));
}
}
Ok(Self {
size,
block_side,
cells,
})
}
}
fn parse_rows(input: &str) -> Result<Vec<Vec<usize>>, SudokuError> {
let mut rows = Vec::new();
for (line_index, line) in input.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let mut row = Vec::new();
for (column_index, token) in line.split_whitespace().enumerate() {
let value = token.parse::<usize>().map_err(|error| {
SudokuError::Parse(format!(
"line {}, value {} is not a number: {error}",
line_index + 1,
column_index + 1
))
})?;
row.push(value);
}
rows.push(row);
}
Ok(rows)
}
fn block_side_for(size: usize) -> Option<usize> {
let mut block_side = 1;
while block_side <= size / block_side {
let square = block_side * block_side;
if square == size {
return Some(block_side);
}
block_side += 1;
}
None
}
#[derive(Clone, Debug)]
struct Solver {
size: usize,
units: Vec<Vec<usize>>,
cell_units: Vec<[usize; 3]>,
peers: Vec<Vec<usize>>,
full_mask: Mask,
}
impl Solver {
fn new(size: usize, block_side: usize) -> Self {
let cell_count = size * size;
let mut units = Vec::with_capacity(size * 3);
let mut cell_units = vec![[0; 3]; cell_count];
for row in 0..size {
let unit_index = units.len();
let unit = (0..size)
.map(|column| cell_index(size, row, column))
.collect();
for column in 0..size {
cell_units[cell_index(size, row, column)][0] = unit_index;
}
units.push(unit);
}
for column in 0..size {
let unit_index = units.len();
let unit = (0..size).map(|row| cell_index(size, row, column)).collect();
for row in 0..size {
cell_units[cell_index(size, row, column)][1] = unit_index;
}
units.push(unit);
}
for block_row in 0..block_side {
for block_column in 0..block_side {
let unit_index = units.len();
let mut unit = Vec::with_capacity(size);
for row_offset in 0..block_side {
for column_offset in 0..block_side {
let row = block_row * block_side + row_offset;
let column = block_column * block_side + column_offset;
let cell = cell_index(size, row, column);
cell_units[cell][2] = unit_index;
unit.push(cell);
}
}
units.push(unit);
}
}
let peers = build_peers(size, &units, &cell_units);
Self {
size,
units,
cell_units,
peers,
full_mask: full_mask(size),
}
}
fn solve(&self, puzzle: &Puzzle) -> Result<SolvedGrid, SudokuError> {
let state = self.initial_state(puzzle)?;
let solved = self.search(state).ok_or(SudokuError::NoSolution)?;
Ok(SolvedGrid {
size: self.size,
values: solved
.values
.into_iter()
.map(|value| value.ok_or(SudokuError::NoSolution))
.collect::<Result<Vec<_>, _>>()?,
})
}
fn initial_state(&self, puzzle: &Puzzle) -> Result<State, SudokuError> {
let mut state = State {
values: vec![None; self.size * self.size],
candidates: vec![self.full_mask; self.size * self.size],
};
for (cell, value) in puzzle.cells.iter().copied().enumerate() {
if let Some(value) = value {
self.assign(&mut state, cell, value)
.map_err(|Contradiction| {
SudokuError::Invalid(format!(
"given value {value} at row {}, column {} conflicts with another given",
cell / self.size + 1,
cell % self.size + 1
))
})?;
}
}
Ok(state)
}
fn search(&self, mut state: State) -> Option<State> {
self.deduce(&mut state).ok()?;
if state.values.iter().all(Option::is_some) {
return Some(state);
}
let cell = self.choose_branch_cell(&state)?;
for value in self.ordered_candidates(&state, cell) {
let mut next = state.clone();
if self.assign(&mut next, cell, value).is_ok()
&& let Some(solution) = self.search(next)
{
return Some(solution);
}
}
None
}
fn deduce(&self, state: &mut State) -> Result<(), Contradiction> {
loop {
let mut progressed = false;
for cell in 0..state.values.len() {
if state.values[cell].is_none() {
match state.candidates[cell].count_ones() {
0 => return Err(Contradiction),
1 => {
let value = only_value(state.candidates[cell]);
self.assign(state, cell, value)?;
progressed = true;
}
_ => {}
}
}
}
for unit in &self.units {
for value in 1..=self.size {
if let Some(cell) = hidden_single_cell(state, unit, value)? {
self.assign(state, cell, value)?;
progressed = true;
}
}
}
if !progressed {
return Ok(());
}
}
}
fn assign(&self, state: &mut State, cell: usize, value: usize) -> Result<(), Contradiction> {
let bit = value_bit(value);
if state.candidates[cell] & bit == 0 {
return Err(Contradiction);
}
if let Some(existing) = state.values[cell] {
return (existing == value).then_some(()).ok_or(Contradiction);
}
for &unit_index in &self.cell_units[cell] {
if self.units[unit_index]
.iter()
.any(|&peer| peer != cell && state.values[peer] == Some(value))
{
return Err(Contradiction);
}
}
state.values[cell] = Some(value);
state.candidates[cell] = bit;
for &peer in &self.peers[cell] {
if state.values[peer] == Some(value) {
return Err(Contradiction);
}
if state.values[peer].is_none() && state.candidates[peer] & bit != 0 {
state.candidates[peer] &= !bit;
if state.candidates[peer] == 0 {
return Err(Contradiction);
}
}
}
Ok(())
}
fn choose_branch_cell(&self, state: &State) -> Option<usize> {
let mut best = None;
for cell in 0..state.values.len() {
if state.values[cell].is_none() {
let candidate_count = state.candidates[cell].count_ones();
let open_peer_count = self.peers[cell]
.iter()
.filter(|&&peer| state.values[peer].is_none())
.count();
let should_replace = match best {
None => true,
Some((_, best_candidate_count, best_open_peer_count)) => {
candidate_count < best_candidate_count
|| (candidate_count == best_candidate_count
&& open_peer_count > best_open_peer_count)
}
};
if should_replace {
best = Some((cell, candidate_count, open_peer_count));
}
}
}
best.map(|(cell, _, _)| cell)
}
fn ordered_candidates(&self, state: &State, cell: usize) -> Vec<usize> {
let mut candidates = mask_values(state.candidates[cell], self.size);
candidates.sort_by(|left, right| {
let right_impact = self.candidate_impact(state, cell, *right);
let left_impact = self.candidate_impact(state, cell, *left);
right_impact.cmp(&left_impact).then_with(|| left.cmp(right))
});
candidates
}
fn candidate_impact(&self, state: &State, cell: usize, value: usize) -> usize {
let bit = value_bit(value);
self.peers[cell]
.iter()
.filter(|&&peer| state.values[peer].is_none() && state.candidates[peer] & bit != 0)
.count()
}
}
#[derive(Clone, Debug)]
struct State {
values: Vec<Option<usize>>,
candidates: Vec<Mask>,
}
#[derive(Clone, Copy, Debug)]
struct Contradiction;
fn build_peers(size: usize, units: &[Vec<usize>], cell_units: &[[usize; 3]]) -> Vec<Vec<usize>> {
let mut peers = Vec::with_capacity(size * size);
for unit_indexes in cell_units {
let mut seen = vec![false; size * size];
for &unit_index in unit_indexes {
for &cell in &units[unit_index] {
seen[cell] = true;
}
}
let cell = peers.len();
seen[cell] = false;
peers.push(
seen.into_iter()
.enumerate()
.filter_map(|(candidate, is_peer)| is_peer.then_some(candidate))
.collect(),
);
}
peers
}
fn hidden_single_cell(
state: &State,
unit: &[usize],
value: usize,
) -> Result<Option<usize>, Contradiction> {
let bit = value_bit(value);
let mut candidate_cell = None;
let mut candidate_count = 0;
for &cell in unit {
match state.values[cell] {
Some(existing) if existing == value => return Ok(None),
None if state.candidates[cell] & bit != 0 => {
candidate_cell = Some(cell);
candidate_count += 1;
}
Some(_) | None => {}
}
}
match candidate_count {
0 => Err(Contradiction),
1 => Ok(candidate_cell),
_ => Ok(None),
}
}
const fn cell_index(size: usize, row: usize, column: usize) -> usize {
row * size + column
}
fn full_mask(size: usize) -> Mask {
if size == Mask::BITS as usize {
Mask::MAX
} else {
(1 << size) - 1
}
}
const fn value_bit(value: usize) -> Mask {
1 << (value - 1)
}
fn only_value(mask: Mask) -> usize {
mask.trailing_zeros() as usize + 1
}
fn mask_values(mask: Mask, size: usize) -> Vec<usize> {
(1..=size)
.filter(|&value| mask & value_bit(value) != 0)
.collect()
}
fn push_padded_number(output: &mut String, value: usize, width: usize) {
let value = value.to_string();
for _ in value.len()..width {
output.push(' ');
}
output.push_str(&value);
}
#[cfg(test)]
mod tests {
use super::{SudokuError, solve_text};
#[test]
fn solves_standard_nine_by_nine() {
let puzzle = "\
5 3 0 0 7 0 0 0 0
6 0 0 1 9 5 0 0 0
0 9 8 0 0 0 0 6 0
8 0 0 0 6 0 0 0 3
4 0 0 8 0 3 0 0 1
7 0 0 0 2 0 0 0 6
0 6 0 0 0 0 2 8 0
0 0 0 4 1 9 0 0 5
0 0 0 0 8 0 0 7 9
";
let solved = solve_text(puzzle).expect("expected puzzle to solve");
assert_eq!(solved.value_at(0, 0), Some(5));
assert_eq!(solved.value_at(8, 8), Some(9));
assert_eq!(
solved.format_plain(),
"\
5 3 4 6 7 8 9 1 2
6 7 2 1 9 5 3 4 8
1 9 8 3 4 2 5 6 7
8 5 9 7 6 1 4 2 3
4 2 6 8 5 3 7 9 1
7 1 3 9 2 4 8 5 6
9 6 1 5 3 7 2 8 4
2 8 7 4 1 9 6 3 5
3 4 5 2 8 6 1 7 9"
);
}
#[test]
fn solves_sixteen_by_sixteen() {
let mut puzzle = sixteen_by_sixteen_solution();
puzzle[0][0] = 0;
puzzle[5][7] = 0;
puzzle[15][15] = 0;
let input = grid_to_text(&puzzle);
let solved = solve_text(&input).expect("expected 16x16 puzzle to solve");
assert_eq!(solved.size(), 16);
assert_eq!(solved.value_at(0, 0), Some(1));
assert_eq!(solved.value_at(5, 7), Some(13));
assert_eq!(solved.value_at(15, 15), Some(15));
}
#[test]
fn rejects_duplicate_givens() {
let puzzle = "\
1 1 0 0
0 0 0 0
0 0 0 0
0 0 0 0
";
let error = solve_text(puzzle).expect_err("duplicate row givens must be invalid");
assert!(matches!(error, SudokuError::Invalid(_)));
}
#[test]
fn rejects_non_square_block_layouts() {
let puzzle = "\
0 0
0 0
";
let error = solve_text(puzzle).expect_err("2x2 is not a valid block layout");
assert_eq!(
error,
SudokuError::Parse(String::from("grid size 2 is not a square block layout"))
);
}
fn sixteen_by_sixteen_solution() -> Vec<Vec<usize>> {
let size = 16;
let block_side = 4;
(0..size)
.map(|row| {
(0..size)
.map(|column| (row * block_side + row / block_side + column) % size + 1)
.collect()
})
.collect()
}
fn grid_to_text(grid: &[Vec<usize>]) -> String {
grid.iter()
.map(|row| {
row.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join("\n")
}
}
+63 -1
View File
@@ -1,3 +1,65 @@
use std::{env, fmt, fs, io, process};
use sudoku_ai::SudokuError;
fn main() {
println!("Hello, world!");
match run(env::args()) {
Ok(solution) => println!("{solution}"),
Err(error) => {
eprintln!("{error}");
process::exit(error.exit_code());
}
}
}
fn run(args: impl IntoIterator<Item = String>) -> Result<String, AppError> {
let mut args = args.into_iter();
let program = args.next().unwrap_or_else(|| String::from("sudoku-ai"));
let Some(path) = args.next() else {
return Err(AppError::Usage { program });
};
if args.next().is_some() {
return Err(AppError::Usage { program });
}
let input = fs::read_to_string(&path).map_err(|source| AppError::Io {
path: path.clone(),
source,
})?;
let solved = sudoku_ai::solve_text(&input)?;
Ok(solved.format_plain())
}
#[derive(Debug)]
enum AppError {
Usage { program: String },
Io { path: String, source: io::Error },
Sudoku(SudokuError),
}
impl AppError {
const fn exit_code(&self) -> i32 {
match self {
Self::Usage { .. } => 2,
Self::Io { .. } | Self::Sudoku(_) => 1,
}
}
}
impl From<SudokuError> for AppError {
fn from(error: SudokuError) -> Self {
Self::Sudoku(error)
}
}
impl fmt::Display for AppError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage { program } => write!(formatter, "usage: {program} <sudoku_file>"),
Self::Io { path, source } => write!(formatter, "failed to read {path}: {source}"),
Self::Sudoku(error) => write!(formatter, "{error}"),
}
}
}