Sudoku¶
Problem Statement¶
A simple sudoku solving problem.
Full example scenario
A standard 9x9 Sudoku puzzle needs solving: filling in the missing digits so that every row, every column and every 3x3 sub-grid contains each of the digits 1 through 9 exactly once. Which digits belong in the empty cells below, marked with periods?
.284763..
...839.2.
7..512.8.
..179..4.
3........
..9...1..
.5..8....
..692...5
..2645..8
The grid has 33 pre-filled cells and 48 empty ones.
Every row, column and 3x3 sub-grid must end up containing each of 1-9 exactly once.
Any cell already filled in above must keep that exact digit in the solution.
The same 33 pre-filled cells can be found in
sudoku.csv, with the columnsrow,col,value.
What an optimal solution looks like
The solver re-solved with the first solution excluded and found no second one — this grid is the puzzle's only valid completion.
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same sudoku problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled, so you can answer with a number instead of prose.
I’ve got a sudoku puzzle I’d like solved automatically. Can you help me build a model for that?
Sudoku is a well-known constraint-satisfaction problem, but the grid size and the exact input/output shape are not fixed by the term alone. Let me check the knowledge base for how this is usually modeled, then ask about grid size first, since it determines every constraint family before anything else can be pinned down.
Question 1: What grid size are you targeting for the puzzle?
9x9 (standard Sudoku) — 9 rows, 9 columns, nine 3x3 sub-grids, digits 1-9
4x4 (mini Sudoku) — 4 rows, 4 columns, four 2x2 sub-grids, digits 1-4
16x16 (super Sudoku) — 16 rows, 16 columns, sixteen 4x4 sub-grids, digits 1-16
General N x N — parameterised so any valid square size works
Standard 9x9 sudoku.
Grid size is fixed. Next open question is how the puzzle itself will arrive — that shapes the parsing logic in the implementation, so it is worth settling before anything else.
Noted — a standard 9x9 Sudoku it is.
Question 2: How would you like to provide the puzzle input (the pre-filled “given” cells)?
Flat string of 81 characters — e.g.
"530070000600195000...",0for empty9x9 nested list / 2D array —
0for emptyList of
(row, col, value)tuples — only the non-empty cells listedPandas DataFrame — columns
row,col,valuefor the pre-filled cells only
A 9x9 grid works, but the raw puzzle I have is text-based — 9 lines of 9 characters, using periods for the empty cells instead of 0. Can the model read that directly, or should I convert it first?
The user’s actual format is close to option 2 but uses periods rather than zeros — no reason to make them convert it by hand, the parser can just read that directly. That still leaves open how the solver should behave on a puzzle with no valid solution at all, which matters for the output contract.
The model can read your text format directly — no conversion needed.
The implementation will include a parser that reads the 9-line string
and treats . as an empty cell.
Question 3: How should the solver handle the case where the puzzle has no valid solution (e.g. an invalid or contradictory input)?
Return a result dictionary with
is_feasible=Falseandsolution=None, no exceptionRaise an exception with a descriptive error message
Print a warning message and return
None
1. A result dictionary with is_feasible=False and solution=None sounds good, no exceptions. Also, I’d like the output to flag if the puzzle had more than one valid solution, not just return the first one it finds — I want to know if the puzzle is well-posed.
That is a real fifth constraint, not just an output preference — the solver needs to search for a second solution after finding the first, which changes the solve procedure itself. Recording it as C5. Still open: what shape the completed grid comes back in.
Specification updated. That is a great requirement — checking for uniqueness is very useful, and the solver will handle it automatically by searching for a second solution after finding the first one.
Question 4: How would you like the completed solution grid to be returned in the output?
9x9 nested list of integers
Same text format as the input — 9 lines of 9 characters
Both — include both representations in the result dictionary
Pandas DataFrame — columns
row,col,value
Both formats would be great — the nested list for programmatic use and the text version for printing. One more thing: the puzzle I actually have is fairly tricky (very few givens), so please make sure whatever test data you use to validate this reflects that — I don’t want the tests to only cover easy puzzles with lots of clues.
Noted for the test scenarios rather than the model itself — a hard, low-clue puzzle exercises the uniqueness check much harder than an easy one does, since presolve alone rarely closes a near-empty grid. One more open question: whether the second solve, for uniqueness, needs a time guard.
Specification updated. The test scenarios now include a hard puzzle with very few givens, a multi-solution case, an infeasible case, and a fully solved edge case.
Question 5: For the uniqueness check, once a first solution is found, the solver will search for a second one. Should there be a time limit applied to this second search, to protect against very long solve times on ambiguous puzzles?
Yes, apply a short fixed time limit (e.g. 10 seconds), reporting
is_unique=Trueconservatively if none is found in timeNo time limit — always run the full second search
Make it configurable — accept an optional
time_limitparameter with a sensible default
3. Let’s make it configurable with a time_limit parameter, defaulting to something reasonable like 10 seconds.
That was the last open modeling question. Everything needed to implement is now recorded, except the puzzle itself hasn’t arrived as a file yet — worth asking for one, since a file is easier to get right than dictating 81 characters over chat.
That completes the specification. One last thing before I generate the specification document in full: could you attach the puzzle itself as a file, rather than pasting it into the chat? A file is easier to double-check for typos than 81 characters typed by hand.
Sure — attaching it now.
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on.
Modeler asked for the puzzle as a file rather than as 81 characters typed into
the chat. Attach sudoku.csv to the conversation and it reads the real
shape of your data instead of guessing at it — one row per pre-filled cell,
row/column indices and all.
row,col,value
0,1,2
0,2,8
0,3,4
0,4,7
0,5,6
0,6,3
1,3,8
1,4,3
1,5,9
1,7,2
2,0,7
2,3,5
2,4,1
2,5,2
2,7,8
3,2,1
3,3,7
3,4,9
3,7,4
4,0,3
5,2,9
5,6,1
6,1,5
6,4,8
7,2,6
7,3,9
7,4,2
7,8,5
8,2,2
8,3,6
8,4,4
8,5,5
8,8,8
sudoku.csv attached
Here’s the puzzle as a list of the pre-filled cells — row, column and value for each one. Any cell not in the file is empty.
Reading the attachment. One row per given cell, three columns — row, col, value — with 0-indexed positions and no header surprises. 33 rows, so 33 givens out of 81 cells. This is functionally the same data as the period-delimited text format already in the specification, just sparse instead of dense; no new entity is needed, only a second loading path that produces the same internal grid.
Got it — 33 pre-filled cells, given as (row, col, value) rows. I
have added a CSV-loading path (set_puzzle_from_csv) alongside the
text-string loader, so both formats populate the same internal grid.
That was the last open item. Shall I proceed to Step 2: Model Implementation using Python with gurobipy?
Where your files live
Everything in the conversation — the file you attached and every artifact Modeler generates from it — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds six items:
Attachment |
What it is |
|---|---|
|
The puzzle’s pre-filled cells you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills in
a missing implementation or test suite, reconciles code that has drifted from the
specification, and runs the tests. Point it at a model written for another solver
or another language and it recovers a specification from that file, asks you to
confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
The Sudoku Solver is a constraint satisfaction problem modelled as an integer program. Given a partially filled 9x9 grid (the puzzle), the model finds an assignment of digits 1-9 to all empty cells such that every row, every column and every 3x3 sub-grid contains each digit exactly once. It also checks whether that solution is unique, which is an important indicator that the puzzle is well-posed.
2. Objective
Find a valid, complete assignment of digits to all empty cells of the puzzle. If multiple solutions exist, detect and report that the puzzle is not uniquely solvable.
3. Constraints
C1 — Row uniqueness. Each digit 1-9 must appear exactly once in every row.
C2 — Column uniqueness. Each digit 1-9 must appear exactly once in every column.
C3 — Sub-grid uniqueness. Each digit 1-9 must appear exactly once in each of the nine 3x3 sub-grids.
C4 — Pre-filled cell fixation. Any cell that already contains a digit in the input puzzle must retain that exact digit in the solution.
C5 — Uniqueness check. After a first valid solution is found, the solver searches for a second solution (with a configurable time limit). If a second solution is found, the puzzle is flagged as not uniquely solvable.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Puzzle |
row, col, value — one row per pre-filled cell |
33 given cells, supplied in |
Solver parameter |
|
Maximum seconds for the uniqueness check’s second solve; default 10.0 |
Any cell not listed in the file is empty. This sparse, one-row-per-given format is how a puzzle exported from a database of Sudoku puzzles typically arrives — most of the grid is blank, so listing only the 33 filled cells is far more compact than writing out all 81.
5. Output
is_feasible(boolean): whether a valid solution was found.is_unique(boolean orNone):Trueif unique,Falseif a second solution exists,Noneif infeasible.solution_grid(9x9 nested list of integers, orNone).solution_text(9-line string mirroring the input format, orNone).
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Hard puzzle with only 17 givens (the theoretical minimum for a uniquely solvable 9x9 puzzle). |
Feasible, unique, all rows/columns/sub-grids satisfied, all givens retained. |
T2 |
Easy puzzle with many givens. |
Feasible, unique, all constraints satisfied. |
T3 |
Under-constrained puzzle (only one or two givens). |
Feasible but not unique — a second solution exists. |
T4 |
Contradictory givens (two identical digits in the same row). |
Reported infeasible: |
T5 |
Already fully filled, valid grid (no empty cells). |
Feasible and unique; |
T6 |
Under-constrained puzzle solved with a very short |
Solver completes within a reasonable time; |
7. Out of scope
Sudoku variants (e.g. Diagonal Sudoku, Killer Sudoku, irregular grids).
Puzzle generation — only solving is in scope.
Graphical or interactive interfaces.
Grid sizes other than 9x9.
With the specification settled, Modeler writes the gurobipy implementation.
This is the version shown running in the Cloud solve tab — it passed every
test in the suite on its very first run.
"""
Sudoku Solver using Gurobi Integer Programming.
Models a standard 9x9 Sudoku puzzle as a binary integer program.
Solves for a valid digit assignment and checks whether the solution is unique.
"""
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
class SudokuSolver:
"""
Solves a 9x9 Sudoku puzzle using binary integer programming.
The puzzle is represented as a multi-line string of 9 rows of 9 characters.
Digits 1-9 indicate pre-filled (given) cells; a period '.' indicates an empty cell.
The solver:
1. Finds a valid solution (if one exists).
2. Checks whether the solution is unique by searching for a second solution.
Usage:
with gp.Env(empty=True) as env:
env.setParam('OutputFlag', 0)
env.start()
with SudokuSolver(env) as solver:
solver.set_puzzle(puzzle_text)
solver.build_model()
solver.solve(time_limit=10.0)
result = solver.get_solution()
solver.display_results()
"""
# Grid dimension
N = 9
# Sub-grid dimension
S = 3
# Digit values (1-indexed)
DIGITS = range(1, 10)
# Row/column indices (0-indexed)
INDICES = range(9)
def __init__(self, env):
"""Initialise the solver with a Gurobi environment."""
self.env = env
self.model = None
# Raw puzzle string (9 lines of 9 chars)
self.puzzle_text = None
# 2D list of given values; 0 means empty
self.givens = None
# Decision variables: x[i, j, v] = 1 if cell (i,j) holds digit v
self.x = None
# Store the first solution grid for uniqueness check
self._first_solution = None
def __enter__(self):
"""Enter context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Dispose the Gurobi model on exit."""
if self.model is not None:
self.model.dispose()
return False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def set_puzzle(self, puzzle_text: str):
"""
Parse and store the puzzle from a period-delimited text string.
Args:
puzzle_text: Multi-line string with 9 rows of 9 characters.
Digits 1-9 are givens; '.' represents an empty cell.
Raises:
ValueError: If the input does not have the expected 9x9 structure.
"""
lines = [line.strip() for line in puzzle_text.strip().splitlines()]
if len(lines) != self.N:
raise ValueError(f"Expected {self.N} rows, got {len(lines)}.")
for idx, line in enumerate(lines):
if len(line) != self.N:
raise ValueError(
f"Row {idx} has {len(line)} characters, expected {self.N}."
)
self.puzzle_text = puzzle_text
# Build givens grid: 0 means empty
self.givens = []
for line in lines:
row = []
for ch in line:
if ch == '.':
row.append(0)
elif ch.isdigit() and ch != '0':
row.append(int(ch))
else:
raise ValueError(
f"Invalid character '{ch}'. Use digits 1-9 or '.' for empty cells."
)
self.givens.append(row)
def set_puzzle_from_csv(self, path: str):
"""
Load the puzzle from a CSV of pre-filled cells only.
Args:
path: Path to a CSV file with columns ``row``, ``col``, ``value``
(0-indexed row/col, digit 1-9). Cells not listed are empty.
"""
frame = pd.read_csv(path)
self.puzzle_text = None
self.givens = [[0] * self.N for _ in range(self.N)]
for _, given in frame.iterrows():
self.givens[int(given["row"])][int(given["col"])] = int(given["value"])
# ------------------------------------------------------------------
# Model building
# ------------------------------------------------------------------
def build_model(self):
"""Create the Gurobi model with variables and constraints."""
self.model = gp.Model("sudoku", env=self.env)
self._create_variables()
self._add_constraints()
# Sudoku is a pure feasibility problem — use a constant objective
self.model.setObjective(0, GRB.MINIMIZE)
def _create_variables(self):
"""
Declare binary decision variables.
x[i, j, v] = 1 if cell (row i, col j) is assigned digit v.
For pre-filled cells the lower/upper bound fixes the digit directly.
"""
self.x = {}
for i in self.INDICES:
for j in self.INDICES:
for v in self.DIGITS:
lb = 1 if self.givens[i][j] == v else 0
# If the cell has a different given, this digit cannot be placed
ub = 0 if (self.givens[i][j] != 0 and self.givens[i][j] != v) else 1
self.x[i, j, v] = self.model.addVar(
lb=lb, ub=ub, vtype=GRB.BINARY,
name=f"x_{i}_{j}_{v}"
)
def _add_constraints(self):
"""Add all Sudoku constraints to the model."""
self._add_cell_constraints()
self._add_row_constraints()
self._add_column_constraints()
self._add_subgrid_constraints()
def _add_cell_constraints(self):
"""Each cell must hold exactly one digit."""
for i in self.INDICES:
for j in self.INDICES:
self.model.addConstr(
gp.quicksum(self.x[i, j, v] for v in self.DIGITS) == 1,
name=f"cell_{i}_{j}"
)
def _add_row_constraints(self):
"""C1: Each digit appears exactly once in every row."""
for i in self.INDICES:
for v in self.DIGITS:
self.model.addConstr(
gp.quicksum(self.x[i, j, v] for j in self.INDICES) == 1,
name=f"row_{i}_digit_{v}"
)
def _add_column_constraints(self):
"""C2: Each digit appears exactly once in every column."""
for j in self.INDICES:
for v in self.DIGITS:
self.model.addConstr(
gp.quicksum(self.x[i, j, v] for i in self.INDICES) == 1,
name=f"col_{j}_digit_{v}"
)
def _add_subgrid_constraints(self):
"""C3: Each digit appears exactly once in each 3x3 sub-grid."""
for bi in range(self.S): # block row index (0,1,2)
for bj in range(self.S): # block col index (0,1,2)
for v in self.DIGITS:
self.model.addConstr(
gp.quicksum(
self.x[bi * self.S + di, bj * self.S + dj, v]
for di in range(self.S)
for dj in range(self.S)
) == 1,
name=f"block_{bi}_{bj}_digit_{v}"
)
# ------------------------------------------------------------------
# Solving
# ------------------------------------------------------------------
def solve(self, time_limit: float = 10.0):
"""
Solve the puzzle and check solution uniqueness.
Args:
time_limit: Maximum seconds allowed for the uniqueness check
(second solve). The first solve is unconstrained.
"""
# --- First solve: find any valid solution ---
self.model.optimize()
if self.model.Status == GRB.INFEASIBLE:
self._first_solution = None
self._is_unique = None
return
if self.model.Status not in (GRB.OPTIMAL, GRB.SUBOPTIMAL):
self._first_solution = None
self._is_unique = None
return
# Extract first solution
self._first_solution = self._extract_grid()
# --- Second solve: check uniqueness (C5) ---
# Add a "no-good" cut that forbids the current solution
no_good = self.model.addConstr(
gp.quicksum(
self.x[i, j, self._first_solution[i][j]]
for i in self.INDICES
for j in self.INDICES
) <= self.N * self.N - 1,
name="no_good_cut"
)
# Apply time limit only to the uniqueness search
self.model.setParam('TimeLimit', time_limit)
self.model.optimize()
if self.model.Status == GRB.INFEASIBLE:
# No second solution exists — puzzle is uniquely solvable
self._is_unique = True
elif self.model.Status in (GRB.OPTIMAL, GRB.SUBOPTIMAL):
# A second solution was found
self._is_unique = False
else:
# Time limit hit or other status — conservatively report unique
self._is_unique = True
# Remove the no-good constraint so the model stays clean
self.model.remove(no_good)
# ------------------------------------------------------------------
# Solution retrieval
# ------------------------------------------------------------------
def _extract_grid(self) -> list:
"""Read the current solver solution into a 9x9 list of integers."""
grid = []
for i in self.INDICES:
row = []
for j in self.INDICES:
for v in self.DIGITS:
if self.x[i, j, v].X > 0.5:
row.append(v)
break
grid.append(row)
return grid
@staticmethod
def _grid_to_text(grid: list) -> str:
"""Convert a 9x9 integer grid to a 9-line string."""
return "\n".join("".join(str(v) for v in row) for row in grid)
def get_solution(self) -> dict:
"""
Return the solution as a structured dictionary.
Returns:
dict with keys:
is_feasible (bool): True if a valid solution was found.
is_unique (bool | None): True = unique, False = multiple solutions,
None = no solution.
solution_grid (list | None): 9x9 nested list of integers, or None.
solution_text (str | None): 9-line solution string, or None.
"""
if self._first_solution is None:
return {
"is_feasible": False,
"is_unique": None,
"solution_grid": None,
"solution_text": None,
}
return {
"is_feasible": True,
"is_unique": self._is_unique,
"solution_grid": self._first_solution,
"solution_text": self._grid_to_text(self._first_solution),
}
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def display_results(self):
"""Print a formatted view of the solution and its status."""
result = self.get_solution()
print("=" * 37)
print(" SUDOKU SOLVER RESULT")
print("=" * 37)
if not result["is_feasible"]:
print("Status : INFEASIBLE — no valid solution exists.")
print("=" * 37)
return
print("Status : Feasible")
if result["is_unique"] is True:
print("Unique : Yes — the puzzle is well-posed.")
elif result["is_unique"] is False:
print("Unique : No — multiple solutions exist.")
else:
print("Unique : Unknown (time limit reached during check).")
print("-" * 37)
grid = result["solution_grid"]
for bi in range(3):
for i in range(3):
row_idx = bi * 3 + i
row = grid[row_idx]
line = " | ".join(
" ".join(str(row[bj * 3 + dj]) for dj in range(3))
for bj in range(3)
)
print(f" {line}")
if bi < 2:
print(" " + "-" * 31)
print("=" * 37)
# ----------------------------------------------------------------------
# Main: solve the running example puzzle, loaded from sudoku.csv
# ----------------------------------------------------------------------
if __name__ == "__main__":
with gp.Env(empty=True) as env:
env.setParam('OutputFlag', 0) # Suppress solver log
env.start()
with SudokuSolver(env) as solver:
solver.set_puzzle_from_csv("sudoku.csv")
solver.build_model()
solver.solve(time_limit=10.0)
solver.display_results()
Alongside the implementation, Modeler writes a pytest suite with one test per
scenario in the specification. Each test uses small hard-coded puzzle strings and
a short solver time limit, so the whole suite runs in well under a second.
"""
Unit tests for sudoku_modeler.py
Covers all test scenarios defined in the specification:
T1 - Hard puzzle (17 givens, known unique solution)
T2 - Easy puzzle (many givens)
T3 - Multiple solutions (under-constrained)
T4 - Infeasible puzzle (contradictory givens)
T5 - Already fully solved puzzle
T6 - Configurable time limit respected
"""
import time
import gurobipy as gp
from sudoku_modeler import SudokuSolver
def make_env():
"""Create a silent Gurobi environment for testing."""
env = gp.Env(empty=True)
env.setParam('OutputFlag', 0)
env.setParam('TimeLimit', 10)
env.start()
return env
def _validate_solution_grid(grid):
"""Assert all rows, columns, and 3x3 sub-grids contain digits 1-9 exactly once."""
digits = set(range(1, 10))
for row in grid:
assert set(row) == digits, f"Row constraint violated: {row}"
for j in range(9):
col = [grid[i][j] for i in range(9)]
assert set(col) == digits, f"Column {j} constraint violated: {col}"
for bi in range(3):
for bj in range(3):
block = [
grid[bi * 3 + di][bj * 3 + dj]
for di in range(3)
for dj in range(3)
]
assert set(block) == digits, f"Block ({bi},{bj}) constraint violated: {block}"
def _check_givens_preserved(grid, puzzle_text):
"""Assert that all pre-filled cells still hold their original value."""
lines = [line.strip() for line in puzzle_text.strip().splitlines()]
for i, line in enumerate(lines):
for j, ch in enumerate(line):
if ch != '.':
assert grid[i][j] == int(ch), (
f"Given at ({i},{j}) changed from {ch} to {grid[i][j]}"
)
# This is a well-known 17-clue puzzle (the theoretical minimum for a
# uniquely solvable 9x9 puzzle).
T1_PUZZLE = """\
.......1.
4........
.2.......
....5.4.7
..8...3..
..1.9....
3..4..2..
.5.1.....
...8.6..."""
def test_T1_hard_puzzle_is_feasible_and_unique():
"""T1: Hard 17-clue puzzle should yield a unique, valid solution."""
env = make_env()
with env:
with SudokuSolver(env) as solver:
solver.set_puzzle(T1_PUZZLE)
solver.build_model()
solver.solve(time_limit=10.0)
result = solver.get_solution()
assert result["is_feasible"] is True
assert result["is_unique"] is True
_validate_solution_grid(result["solution_grid"])
_check_givens_preserved(result["solution_grid"], T1_PUZZLE)
T2_PUZZLE = """\
53..7....
6..195...
.98....6.
8...6...3
4..8.3..1
7...2...6
.6....28.
...419..5
....8..79"""
def test_T2_easy_puzzle_is_feasible_and_unique():
"""T2: Easy puzzle with many givens should solve quickly and be unique."""
env = make_env()
with env:
with SudokuSolver(env) as solver:
solver.set_puzzle(T2_PUZZLE)
solver.build_model()
solver.solve(time_limit=10.0)
result = solver.get_solution()
assert result["is_feasible"] is True
assert result["is_unique"] is True
_validate_solution_grid(result["solution_grid"])
_check_givens_preserved(result["solution_grid"], T2_PUZZLE)
# Only two givens: top-left = 1, bottom-right = 9.
T3_PUZZLE = """\
1........
.........
.........
.........
.........
.........
.........
.........
........9"""
def test_T3_multiple_solutions_detected():
"""T3: Under-constrained puzzle should be feasible but not unique."""
env = make_env()
with env:
with SudokuSolver(env) as solver:
solver.set_puzzle(T3_PUZZLE)
solver.build_model()
solver.solve(time_limit=10.0)
result = solver.get_solution()
assert result["is_feasible"] is True
assert result["is_unique"] is False
assert result["solution_grid"] is not None
_validate_solution_grid(result["solution_grid"])
# Two 1s in the same row — direct contradiction of row uniqueness.
T4_PUZZLE = """\
11.......
.........
.........
.........
.........
.........
.........
.........
........."""
def test_T4_infeasible_puzzle():
"""T4: Puzzle with two identical digits in the same row must be infeasible."""
env = make_env()
with env:
with SudokuSolver(env) as solver:
solver.set_puzzle(T4_PUZZLE)
solver.build_model()
solver.solve(time_limit=10.0)
result = solver.get_solution()
assert result["is_feasible"] is False
assert result["is_unique"] is None
assert result["solution_grid"] is None
assert result["solution_text"] is None
T5_PUZZLE = """\
534678912
672195348
198342567
859761423
426853791
713924856
961537284
287419635
345286179"""
def test_T5_already_solved_puzzle():
"""T5: A fully pre-filled valid grid should be accepted as-is."""
env = make_env()
with env:
with SudokuSolver(env) as solver:
solver.set_puzzle(T5_PUZZLE)
solver.build_model()
solver.solve(time_limit=10.0)
result = solver.get_solution()
assert result["is_feasible"] is True
assert result["is_unique"] is True
assert result["solution_text"] == T5_PUZZLE.strip()
def test_T6_time_limit_respected():
"""T6: Uniqueness check should complete within the specified time limit."""
time_limit = 1.0
env = make_env()
start = time.time()
with env:
with SudokuSolver(env) as solver:
solver.set_puzzle(T3_PUZZLE)
solver.build_model()
solver.solve(time_limit=time_limit)
result = solver.get_solution()
elapsed = time.time() - start
assert result["is_feasible"] is True
assert result["is_unique"] is not None
assert elapsed < time_limit + 30, f"Took too long: {elapsed:.1f}s"
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve.
This all looks good, go ahead and implement it. And here’s the actual puzzle I want solved as the running example, in that same period-delimited format:
.284763..
...839.2.
7..512.8.
..179..4.
3........
..9...1..
.5..8....
..692...5
..2645..8
The specification’s five constraints and the I/O contract from Q1-5 are already fully pinned, so there is no open implementation detail left to ask about — variable shape, constraint families and the output dictionary all follow directly from the spec. Writing the implementation and the test suite now, then running both against the supplied puzzle.
The run
All 7 tests pass on the first run — 6 from the specification plus one extra test against your own puzzle:
$ pytest test_sudoku_modeler.py -q
.......
7 passed in 0.13s
A closer look at the Gurobi log for your puzzle specifically shows why it resolves instantly:
Optimize a model with 324 rows, 729 columns and 2916 nonzeros (Min)
Model fingerprint: 0xdd8e9be4
Variable types: 0 continuous, 729 integer (729 binary)
Coefficient statistics:
Matrix range [1e+00, 1e+00]
Objective range [0e+00, 0e+00]
Bounds range [1e+00, 1e+00]
RHS range [1e+00, 1e+00]
Presolve removed 324 rows and 729 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Solution count 1: 0
Optimal solution found (tolerance 1.00e-04)
Best objective 0.000000000000e+00, best bound 0.000000000000e+00, gap 0.0000%
“Presolve removed 324 rows and 729 columns” — the model was resolved entirely by constraint propagation, with zero simplex iterations and zero branch-and-bound nodes. This puzzle has 33 givens, well above the 17-clue minimum, so fixing each given cascades through its row, column and box neighbours until every remaining cell is forced. The second solve, for the uniqueness check, hits the same infeasibility instantly once the no-good cut is added — there is no second solution.
All tests pass, and the puzzle you gave me is feasible and uniquely solvable. Here is the full summary:
Results
Field |
Value |
|---|---|
|
|
|
|
Givens |
33 of 81 cells |
Files created
File |
Description |
|---|---|
|
Complete problem specification |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1-T6 |
Test results
Test |
Scenario |
Result |
|---|---|---|
T1 |
Hard 17-clue puzzle is feasible and unique |
PASSED |
T2 |
Easy puzzle with many givens is feasible and unique |
PASSED |
T3 |
Under-constrained puzzle is feasible but not unique |
PASSED |
T4 |
Contradictory givens are infeasible |
PASSED |
T5 |
Already-solved grid is accepted as-is |
PASSED |
T6 |
A short time limit is respected |
PASSED |
Let me know if you would like any changes, or if you’d like me to generate the mathematical formulation.
That log lands in the workspace as execution.log alongside the code, so the
solve is auditable rather than asserted.
The loop ends one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct — that second path is what the Changing the model tab demonstrates.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 729 binary variables and 324 constraints, is comfortably inside the cap — a super-sized variant with a much larger grid could exceed it.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded puzzles and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts puzzle generation and grid-quality checks out of scope — the model only ever tries to solve whatever it is handed. Ask for a change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order.
One more change: I’d like the solver to reject a puzzle up front, before even trying to solve it, if it doesn’t have “enough” givens to be a well-posed puzzle — rather than spending solve time discovering that later via the uniqueness check.
This needs a numeric threshold, and “enough” has no single correct answer — it could be the strict combinatorial minimum, a configurable value, a more conservative practical default, or a density-based fraction of the grid. Each has a real trade-off (strictness vs. flexibility vs. simplicity), and picking one silently would bake in an assumption the user has not actually stated. Surfacing the options rather than guessing.
Great idea. Before I implement this I have one clarifying question, since the threshold is a design decision with real consequences.
Question: What rule should determine “enough givens” for a puzzle to pass the upfront check?
Theoretical minimum per grid size — 17 for 9x9. Strict and grounded in combinatorics, but a puzzle at exactly the minimum is still very likely under-constrained in practice.
Configurable minimum threshold — an optional
min_givensparameter onsolve(), with a sensible default (17 for 9x9). The user can override it.Practical heuristic — a higher, more conservative default (e.g. 22) reflecting what is realistic in actual puzzles, while still configurable.
Density-based rule — a fraction of total cells (e.g. at least 25% filled), scaling automatically without per-grid-size constants.
Let’s go with option 2, a configurable min_givens parameter with sensible defaults per grid size (17 for 9x9, matching the T1 test we already have). Go ahead and implement it.
Specification, then code, then tests
The specification changes first. Section 3 gains a new constraint:
C6 -- Minimum givens check (upfront, pre-solve). Before invoking the
optimizer, count the pre-filled cells. If the count is below the
configured min_givens threshold (default 17 for 9x9), reject the puzzle
immediately rather than spending solve time on it. Pass min_givens=0 to
disable the check.
The implementation follows the specification, not the other way around:
def solve(self, time_limit: float = 10.0):
- # --- First solve: find any valid solution ---
+ def solve(self, time_limit: float = 10.0, min_givens: int = 17):
+ # --- C6: reject under-constrained puzzles before solving ---
+ given_count = sum(1 for row in self.givens for v in row if v != 0)
+ if given_count < min_givens:
+ self._first_solution = None
+ self._is_unique = None
+ self._insufficient_givens = True
+ return
+
+ # --- First solve: find any valid solution ---
self.model.optimize()
The suite gains one test per new behaviour — rejecting a below-threshold puzzle, and accepting T1 because it sits exactly at the default:
def test_T15_insufficient_givens_rejected_upfront():
"""T15: a puzzle with far fewer than 17 givens is rejected before
the optimizer ever runs."""
result = run(SPARSE_PUZZLE) # 2 givens
assert result["is_feasible"] is False
assert result["insufficient_givens"] is True
assert result["solution_grid"] is None
def test_T16_exactly_at_default_threshold_is_accepted():
"""T16: T1 has exactly 17 givens -- the default threshold -- and is
still accepted and solved."""
result = run(T1_PUZZLE)
assert result["insufficient_givens"] is False
assert result["is_feasible"] is True
assert result["is_unique"] is True
Modeler re-runs the whole suite, not just the new tests — the point of propagating in this order is that nothing is left stale behind the change.
Where it stops and asks
The min-givens question above was one moment where Modeler paused before implementing. A second, sharper one surfaced when checking how this change interacts with an existing test scenario rather than a new one. T5, in the original specification, is an already fully-filled, valid grid — every cell is a given, well above any givens threshold, so it is unaffected by C6. But asking about it in combination with a different variant exposes a real gap: what if the grid’s rows and columns are valid, but it fails a stricter rule the mode is meant to enforce?
Going back to the Diagonal Sudoku mode idea: for T5 (a fully pre-filled, already-solved grid, accepted as-is under the standard rules) – if a stricter diagonal rule were applied to that same grid, and its diagonals happen not to satisfy it, what should happen? Should the solver still report it as feasible since it’s already fully filled, or should it now report infeasible under the stricter rule?
This is a genuine ambiguity, not a bug to fix. Two readings are both defensible: (A) a diagonal rule is a model constraint like any other, so a grid that violates it is infeasible, full stop – even if every cell was already filled in. (B) the grid is “given,” so the solver could report it feasible regardless and merely flag the violation separately. Reading B needs a new output field that does not exist anywhere else in the model, purely to special-case an already-filled grid. I should report both readings and ask which one matches what the user actually wants, rather than picking silently.
Two readings are both defensible here, and they give different answers for the exact same input grid:
Reading |
Outcome for a fully-filled grid whose diagonals happen to repeat a digit |
|---|---|
A — the rule applies unconditionally |
|
B — “given” cells are exempt from stricter checks |
|
Which is closer to what you meant?
Option A makes sense – go with that.
Confirmed — Reading A. Concretely: with a diagonal rule active, the pre-filled cell fixation already binds every cell to a fixed value; if those fixed values happen to violate the diagonal constraint, Gurobi’s presolve detects the contradiction immediately and the model reports infeasible. No special case is needed in the code — it falls out of the existing constraints once fixation and the diagonal rule are both present. I’ve added T5b to the specification to record this reading explicitly, so it stays documented rather than implicit.
This is the loop ending in its second way: not a bug Modeler can fix by rewriting code, but a genuine ambiguity in what “feasible” should mean once a stricter rule is layered on top of an already-filled grid. It names both readings, their different consequences, and hands the decision back rather than guessing — exactly the moment a spec-first workflow is meant to catch, before a silently-chosen reading ships as if it were the only possible one.
The answer flips
T5’s grid is a valid standard Sudoku solution — every row, column and 3x3
box already contains 1-9 exactly once. But its main diagonal reads
5,7,8,7,5,4,2,3,9 (5 and 7 each appear twice) and its anti-diagonal reads
2,4,5,1,5,9,1,8,3 (5 and 1 each appear twice). Neither diagonal is a
permutation of 1-9, so the same grid gets opposite verdicts depending only on
which rule set is active:
Rule set applied to the T5 grid |
Rows / columns / boxes |
Diagonals |
|---|---|---|
Standard mode |
Valid |
Not checked |
Stricter diagonal mode (Reading A) |
Valid |
Invalid — infeasible |
The grid did not change at all; only the rule set applied to it did. That is what makes this a specification question rather than a code bug — the fix, if the user had wanted Reading B instead, would be a new output field and a documented exemption, not a patch to a broken constraint.
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the puzzle owner can read them without any background in optimization. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(i, j \in \mathcal{I} = \{0, \dots, 8\}\): row and column indices.
\(v \in \mathcal{V} = \{1, \dots, 9\}\): valid digit values.
\(b_r, b_c \in \mathcal{B} = \{0, 1, 2\}\): 3x3 sub-grid block indices.
\(\mathcal{G} \subseteq \mathcal{I} \times \mathcal{I} \times \mathcal{V}\): given cells — triples \((i,j,v)\) where cell \((i,j)\) is pre-filled with digit \(v\).
Parameters
\(g_{ij}\): pre-filled value at cell \((i,j)\), or 0 if empty.
\(T\): time limit (seconds) for the uniqueness-check second solve.
Decision variables
\(x_{ijv} \in \{0, 1\}\): 1 if cell \((i,j)\) is assigned digit \(v\), else 0. Given cells fix the variable directly through its bounds:
Objective function
Sudoku is a pure constraint-satisfaction problem — a constant objective is used:
Constraints
Every cell must hold exactly one digit — a foundational assignment constraint implicit in how \(x_{ijv}\) is defined, so it carries no C-label of its own:
C1 — Row uniqueness. Each digit appears exactly once in every row.
C2 — Column uniqueness. Each digit appears exactly once in every column.
C3 — Sub-grid uniqueness. Each digit appears exactly once in each of the nine 3x3 boxes.
C4 — Pre-filled cell fixation (enforced via the variable bounds above, not as an explicit constraint row).
C5 — Uniqueness no-good cut (added dynamically after the first solve). Let \(v^*_{ij}\) be the digit assigned to cell \((i,j)\) in the first solution. The cut forbids that exact assignment, and the model is re-solved under time limit \(T\):
Complete model
With C4 applied as variable bounds at construction time, and C5 appended dynamically after the first solve.
Additional notes
The objective is constant because Sudoku has no cost or preference to optimize — every feasible assignment is equally “good.” The solver’s job is entirely constraint satisfaction.
C4 is implemented through variable bounds rather than as separate constraint rows, since fixing a binary variable’s lower and upper bound to the same value is handled directly by presolve without adding to the constraint matrix.
The no-good cut in C5 is the weakest possible cut — it only forbids one specific combination of 81 variable assignments. It is still sufficient in practice: a uniquely solvable puzzle has no second solution at all, so the solver detects infeasibility through propagation alone, as the Cloud solve tab’s log shows for this puzzle (zero branch-and-bound nodes). The time limit \(T\) exists only to bound genuinely ambiguous puzzles.
For a well-posed puzzle with 17 or more givens, the row/column/sub-grid constraints combined with the C4 bounds typically produce a fully propagated presolve with no branching at all — the puzzle’s own givens do the work.
Note that this is the formulation of the original model, before the minimum-givens check added in the Changing the model tab — that addition is a pre-solve guard on the given count, not a model constraint, so it does not change any line above. Gurobi’s own published Sudoku example uses the same \(x_{ijv}\) binary-assignment formulation for the row, column and sub-grid constraints above; the uniqueness no-good cut (C5 here) is specific to this conversation’s requirement to detect ambiguous puzzles.
### Sets and indices
- $i, j \in \mathcal{I} = \{0, \dots, 8\}$: row and column indices.
- $v \in \mathcal{V} = \{1, \dots, 9\}$: valid digit values.
- $b_r, b_c \in \mathcal{B} = \{0, 1, 2\}$: 3x3 sub-grid block indices.
- $\mathcal{G} \subseteq \mathcal{I} \times \mathcal{I} \times \mathcal{V}$: given cells — triples $(i,j,v)$ where cell $(i,j)$ is pre-filled with digit $v$.
### Parameters
- $g_{ij}$: pre-filled value at cell $(i,j)$, or 0 if empty.
- $T$: time limit (seconds) for the uniqueness-check second solve.
### Decision variables
- $x_{ijv} \in \{0, 1\}$: 1 if cell $(i,j)$ is assigned digit $v$, else 0. Given cells fix the variable directly through its bounds:
$$
x_{ijv} =
\begin{cases}
1 & \text{if } g_{ij} = v \\
0 & \text{if } g_{ij} \neq 0 \text{ and } g_{ij} \neq v \\
\in \{0,1\} & \text{if } g_{ij} = 0
\end{cases}
$$
### Objective function
Sudoku is a pure constraint-satisfaction problem — a constant objective is used:
$$
\min \; 0
$$
### Constraints
Every cell must hold exactly one digit — a foundational assignment constraint implicit in how $x_{ijv}$ is defined, so it carries no C-label of its own:
$$
\sum_{v \in \mathcal{V}} x_{ijv} = 1 \qquad \forall\, i, j \in \mathcal{I}
$$
**C1 — Row uniqueness.** Each digit appears exactly once in every row.
$$
\sum_{j \in \mathcal{I}} x_{ijv} = 1 \qquad \forall\, i \in \mathcal{I},\ v \in \mathcal{V}
$$
**C2 — Column uniqueness.** Each digit appears exactly once in every column.
$$
\sum_{i \in \mathcal{I}} x_{ijv} = 1 \qquad \forall\, j \in \mathcal{I},\ v \in \mathcal{V}
$$
**C3 — Sub-grid uniqueness.** Each digit appears exactly once in each of the nine 3x3 boxes.
$$
\sum_{d_r=0}^{2} \sum_{d_c=0}^{2} x_{\,(3b_r + d_r),\,(3b_c + d_c),\,v} = 1 \qquad \forall\, b_r, b_c \in \mathcal{B},\ v \in \mathcal{V}
$$
**C4 — Pre-filled cell fixation** (enforced via the variable bounds above, not as an explicit constraint row).
**C5 — Uniqueness no-good cut** (added dynamically after the first solve). Let $v^*_{ij}$ be the digit assigned to cell $(i,j)$ in the first solution. The cut forbids that exact assignment, and the model is re-solved under time limit $T$:
$$
\sum_{i \in \mathcal{I}} \sum_{j \in \mathcal{I}} x_{i,\,j,\,v^*_{ij}} \;\leq\; 80
$$
### Complete model
$$
\begin{aligned}
\min \quad & 0 \\
\text{s.t.} \quad & \sum_{v \in \mathcal{V}} x_{ijv} = 1 & \forall\, i, j \in \mathcal{I} \\
& \sum_{j \in \mathcal{I}} x_{ijv} = 1 & \forall\, i \in \mathcal{I},\ v \in \mathcal{V} \\
& \sum_{i \in \mathcal{I}} x_{ijv} = 1 & \forall\, j \in \mathcal{I},\ v \in \mathcal{V} \\
& \sum_{d_r=0}^{2} \sum_{d_c=0}^{2} x_{\,(3b_r + d_r),\,(3b_c + d_c),\,v} = 1 & \forall\, b_r, b_c \in \mathcal{B},\ v \in \mathcal{V} \\
& x_{ijv} \in \{0, 1\} & \forall\, i, j \in \mathcal{I},\ v \in \mathcal{V}
\end{aligned}
$$
With C4 applied as variable bounds at construction time, and C5 appended dynamically after the first solve.
### Additional notes
- The objective is constant because Sudoku has no cost or preference to optimize — every feasible assignment is equally "good." The solver's job is entirely constraint satisfaction.
- C4 is implemented through variable bounds rather than as separate constraint rows, since fixing a binary variable's lower and upper bound to the same value is handled directly by presolve without adding to the constraint matrix.
- The no-good cut in C5 is the weakest possible cut — it only forbids one specific combination of 81 variable assignments. It is still sufficient in practice: a uniquely solvable puzzle has no second solution at all, so the solver detects infeasibility through propagation alone, as the Cloud solve tab's log shows for this puzzle (zero branch-and-bound nodes). The time limit $T$ exists only to bound genuinely ambiguous puzzles.
- For a well-posed puzzle with 17 or more givens, the row/column/sub-grid constraints combined with the C4 bounds typically produce a fully propagated presolve with no branching at all — the puzzle's own givens do the work.
Note that this is the formulation of the original model, before the minimum-givens check added in the **Changing the model** tab — that addition is a pre-solve guard on the given count, not a model constraint, so it does not change any line above. Gurobi's own [published Sudoku example](https://docs.gurobi.com/projects/examples/en/current/examples/sudoku.html) uses the same $x_{ijv}$ binary-assignment formulation for the row, column and sub-grid constraints above; the uniqueness no-good cut (C5 here) is specific to this conversation's requirement to detect ambiguous puzzles.