This post shows how Sudoku—a constraint satisfaction puzzle—can be modeled as a Petri net and analyzed using ODE simulation. We'll use a 4×4 mini-Sudoku to keep things comprehensible.
Initial: Solution:
+---+---+---+---+ +---+---+---+---+
| 1 | | . | . | | 1 | 2 | 4 | 3 |
+---+---+---+---+ +---+---+---+---+
| . | . | 2 | . | | 3 | 4 | 2 | 1 |
+===+===+===+===+ -> +===+===+===+===+
| . | 3 | . | . | | 2 | 3 | 1 | 4 |
+---+---+---+---+ +---+---+---+---+
| . | . | . | 4 | | 4 | 1 | 3 | 2 |
+---+---+---+---+ +---+---+---+---+
Constraints: Each row, column, and 2×2 block
must contain digits 1-4 exactly once.
The model has three layers:
Layer 1: CELLS Layer 2: HISTORY Layer 3: CONSTRAINTS
+-----------------+ +-----------------+ +-----------------+
| 16 cell places | -> | 64 history | --> | 12 collector |
| (empty = token) | | places | | transitions |
+-----------------+ +-----------------+ +-----------------+
|
v
+-------------+
| solved |
| (0-12 tok) |
+-------------+
Each cell holds a token when empty:
C0 C1 C2 C3
+-----+-----+-----+-----+
R0 | P00 | P01 | P02 | P03 | P00=0 (has 1)
| 0 | 1 | 1 | 1 | P01=1 (empty)
+-----+-----+-----+-----+
R1 | P10 | P11 | P12 | P13 | P12=0 (has 2)
| 1 | 1 | 0 | 1 |
+-----+-----+-----+-----+
R2 | P20 | P21 | P22 | P23 | P21=0 (has 3)
| 1 | 0 | 1 | 1 |
+-----+-----+-----+-----+
R3 | P30 | P31 | P32 | P33 | P33=0 (has 4)
| 1 | 1 | 1 | 0 |
+-----+-----+-----+-----+
For each cell × digit combination, a transition writes the digit:
Transition D2_01: History place _D2_01:
"Write digit 2 at (0,1)" "Digit 2 is at (0,1)"
+------+
P01 --| D2 |---> _D2_01
(1) | _01 | (0->1)
+------+
Firing consumes cell token, creates history token.
Total: 16 cells × 4 digits = 64 digit transitions and 64 history places.
When all 4 digits appear in a row/column/block, a collector fires:
Row 0 Complete:
_D1_0? --+
_D2_0? --+---> [Row0_Complete] ---> solved
_D3_0? --+
_D4_0? --+ (fires when row 0 has 1,2,3,4)
4 Row + 4 Column + 4 Block collectors = 12 total
The solved place accumulates tokens: 12 tokens = puzzle solved.
Step-by-step for cell (0,1):
1. Initial: P01 = 1 (cell empty)
2. Player considers digit 2:
Transition D2_01 is ENABLED (P01 has token)
3. Firing D2_01:
• P01: 1 → 0 (cell now filled)
• _D2_01: 0 → 1 (history records "2 at (0,1)")
4. When Row 0 has all digits placed:
Row0_Complete fires → solved gets +1 token
Converting to ODEs lets us predict outcomes without exhaustive search:
For each candidate move:
1. Create hypothetical state
2. Run ODE simulation (t=0 to t=3.0)
3. Measure token flow to 'solved' place
4. Higher flow = move more likely leads to solution
Cell (0,1) is empty. Which digit should go there?
Current Board: Candidate Scores:
+---+---+---+---+ Digit solved_flow
| 1 | ? | . | . | ----- -----------
+---+---+---+---+ 1 blocked (row has 1)
| . | . | 2 | . | 2 0.31 <-- best
+===+===+===+===+ 3 blocked (block has 3)
| . | 3 | . | . | 4 0.28
+---+---+---+---+
| . | . | . | 4 |
+---+---+---+---+
ODE shows digit 2 produces highest flow to 'solved'.
The model structure encodes all constraints—conflicts appear as zero-flow transitions, valid moves as positive flow.
+-------------------------------------------------------------+
| 4x4 SUDOKU PETRI NET |
+-------------------------------------------------------------+
| |
| CELLS (16) HISTORY (64) SOLVED |
| +--+--+--+--+ +-------------+ +--------+ |
| | | | | | | _D1_00..03 | | | |
| +--+--+--+--+ D# | _D2_00..03 | R0 | | |
| | | | | | ---> | _D3_00..03 | ---> | 0-12 | |
| +--+--+--+--+ 64 | _D4_00..03 | C0 | tokens | |
| | | | | | txns | ... | B0 | | |
| +--+--+--+--+ | (64 places) | 12 | | |
| | | | | | +-------------+ txns +--------+ |
| +--+--+--+--+ |
| |
| Token = empty Token = digit placed |
| No token = filled 12 tokens = solved |
+-------------------------------------------------------------+
The same pattern scales:
| Component | 4×4 | 9×9 |
|---|---|---|
| Cell places | 16 | 81 |
| Digits | 4 | 9 |
| History places | 64 | 729 |
| Digit transitions | 64 | 729 |
| Row collectors | 4 | 9 |
| Column collectors | 4 | 9 |
| Block collectors | 4 | 9 |
| Max solved tokens | 12 | 27 |
A naive ODE simulation of the full 9×9 model (729 transitions!) would be prohibitively slow. We tune for insight, not precision:
Parameter Default Tuned Why
─────────────────────────────────────────────────────
Time horizon t=10.0 t=3.0 We only need relative rankings
Abstol 1e-6 1e-4 Looser tolerance, faster solve
Reltol 1e-3 1e-3 Already reasonable
Step size (dt) 0.01 0.2 Larger steps, fewer iterations
Max iterations 100000 1000 Early termination OK
We don't need the ODE to converge to exact token counts. We need it to rank candidates:
Move A: solved flow = 0.31 ← Best
Move B: solved flow = 0.28
Move C: solved flow = 0.15
Move D: blocked (conflict)
Short simulations with loose tolerances still preserve ordering. A move that produces higher flow at t=3.0 will generally produce higher flow at t=10.0.
| Model | Naive | Tuned | Speedup |
|---|---|---|---|
| 4×4 (64 transitions) | 0.8s | 0.04s | 20× |
| Tic-tac-toe (34 transitions) | 2.1s | 0.1s | 21× |
| 9×9 (729 transitions) | ~60s | ~3s | 20× |
This makes interactive analysis feasible—evaluating all candidates for a cell takes seconds, not minutes.
cd examples/sudoku
go run ./cmd --size 4x4 --ode --analyze
Data generated with go-pflow
For more on this approach: Declarative Differential Models