← Home
sudoku petri-net ode ddm

Sudoku Petri-Net Model

Prerequisites: Tic-Tac-Toe Model for the cell / history / collector pattern this borrows, and Declarative Differential Models for what "run the ODE" means here.

We model a 4×4 mini-Sudoku as a Petri net in the same three layers as tic-tac-toe — cells, digit moves that leave history, collectors that feed a solved place — and ask whether an ODE simulation of that net can rank candidate moves. The generator is createODENet in go-pflow's examples/sudoku/cmd/model.go, and the nets it produces are committed beside it as sudoku-4x4-ode.jsonld and sudoku-9x9-ode.jsonld. Reading that code again in October 2026 showed the net does less than the first version of this post said; what follows describes what the code builds, and What Changed lists the corrections.

The Puzzle

  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.

Petri Net Structure

Layer Structure

The 4×4 net has 81 places — 16 cells, 64 history places, solved — and 60 transitions: 48 digit moves and 12 constraint collectors. Those are the counts in the committed sudoku-4x4-ode.jsonld (60 Transition nodes; 81 places and 300 arcs make up the rest of its 441). The figure's "64 txns" is the empty-grid count; the generator only makes moves for empty cells.

Layer 1: Cell Places

Cell Places

Each empty cell place P{r}_{c} holds one token. The four given cells start at 0, and the history place for their clue starts at 1 instead — _D1_0_0 is marked from the beginning because the puzzle gives a 1 at (0,0).

Layer 2: Digit Transitions & History

For each empty cell and each digit, a transition writes the digit:

Transition D2_0_1:           History place _D2_0_1:
"Place 2 at (0,1)"           "Digit 2 is at (0,1)"

       +-------+
P0_1 --| D2_0_1|---> _D2_0_1
(1)    +-------+     (0->1)

Firing consumes the cell token, creates a history token.

That gives 12 empty cells × 4 digits = 48 digit transitions, while history places are made for every cell, 16 × 4 = 64. A digit transition has exactly one input arc, from its cell, and one output arc, to its history place. Nothing from a row, column or block touches it, so the net itself does not stop us writing a second 1 into row 0; whatever constraint the model carries has to live in the collectors.

Layer 3: Constraint Collectors

Constraint Collectors

There are 12 collectors — 4 rows, 4 columns, 4 blocks — each with one output arc into solved, whose capacity is 12. The figure draws Row0_Complete as firing "when row 0 has 1, 2, 3, 4", and that is what it was meant to do. The generator wires it differently: the collector gets an input arc from every history place in its row, all 4 digits × 4 cells = 16 places, so it is enabled only when every cell of the row holds every digit at once. A cell has one token to give, so on any legal fill 4 of those 16 places are marked and the collector stays disabled. As built, solved cannot reach 12; it cannot leave 0.

Firing a Move

Cell (0,1):

1. Initial: P0_1 = 1 (cell empty)

2. D2_0_1 is enabled (P0_1 has a token) — and so are
   D1_0_1, D3_0_1 and D4_0_1, by the same arc

3. Firing D2_0_1:
   • P0_1:    1 → 0 (cell now filled)
   • _D2_0_1: 0 → 1 (history records "2 at (0,1)")

4. Row0_Complete is still not enabled: it also needs
   _D1_0_1, _D3_0_1, _D4_0_1, and _D2_0_0 … _D4_0_0
   for the given cell, none of which can ever be marked

ODE Simulation

The method carried over from tic-tac-toe is to score each candidate by simulating the net from the marking the move would leave, and measuring how much reaches solved. As a procedure (a sketch, not code from the repo):

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

On this net that procedure gives every candidate the same score, zero. go-pflow's mass-action rate law sets a transition's flux to its rate times the product of its input places, and to 0 outright if any input is at 0 (solver/ode.go). Every row, column and block of the sample puzzle contains one given cell; a given cell has no digit transitions, so three of its four history places are 0 for all t; and each collector has those places among its inputs. So every collector's flux is 0 from the first step, whichever move we tried. Separately, the four digit transitions out of an empty cell share one input and one rate (SetRates defaults every transition to 1.0), so the ODE splits the cell's token evenly between them and cannot tell 2 from 4 at (0,1) even before the collectors come in. We got this by reading the generator and the solver against the committed net, not by re-running it.

Example: Evaluating Cell (0,1)

ODE Evaluation

The scores in this figure — 0.31 for 2, 0.28 for 4 — are illustrative; no run in the repo produces them, and by the argument above the current net would score both 0. The two exclusions are the reader's reasoning, not the net's. Digit 1 is out because row 0 already has a 1; digit 3 is out because column 1 has the 3 at (2,1) — the figure says block 0, but (2,1) is in the bottom-left block. Neither shows up as zero flow in the model, where D1_0_1 and D3_0_1 are exactly as enabled as D2_0_1.

Scaling to 9×9

The same generator builds the 9×9 net:

Component 4×4 9×9
Cell places 16 81
Digits 4 9
History places 64 729
Digit transitions (sample puzzle) 48 459
Digit transitions (empty grid) 64 729
Row / column / block collectors 4 / 4 / 4 9 / 9 / 9
Inputs per collector 16 81
solved capacity 12 27

The 9×9 sample is the 30-clue puzzle in the example's README, so 51 empty cells and 459 digit moves; the committed sudoku-9x9-ode.jsonld has 486 transitions, which is 459 plus the 27 collectors. That README says 729 digit transitions, which is only true of an empty grid. Every row, column and block of that puzzle also has a given, so the 9×9 collectors are starved the same way the 4×4 ones are.

Tuning the ODE

A full-precision solve of the 9×9 net is slow, so the benchmark in sudoku_bench_test.go runs Tsit5 with looser settings than solver.DefaultOptions():

Parameter         Default    Benchmark  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     100000     The benchmark leaves it alone

The t=10 horizon is tic-tac-toe's original one, not a solver default — the horizon is passed with the problem. The reason for cutting it is that ranking only needs candidates in the right order, not converged token counts, and the bet is that a move with more flow at t=3.0 also has more at t=10.0. Nothing in the repo tests that bet for Sudoku, and with every score at 0 there is nothing to order yet. The tic-tac-toe example is where the same cut (10 → 3, abstol 1e-4, dt 0.2) was made first; its README reports 21× from it, about 0.04 s per move evaluation.

Performance

benchmark_results.txt records one run on an Apple M4 Max:

Benchmark Time per evaluation
4×4, benchmark settings 0.067 s
4×4, tighter tolerance 14.8 s
9×9, benchmark settings 4.58 s

That file names benchmarks (…SingleEvaluation, …WithTighterTolerance) the current test file no longer has — it now has BenchmarkSudoku4x4ODE, 9x9ODE and 16x16ODE — so the tighter-tolerance settings behind the 14.8 s are not in the repo, and the roughly 220× gap between those two 4×4 rows is against settings we can't name. The first version of this post gave 0.8 s → 0.04 s for 4×4 and ~60 s → ~3 s for 9×9; we can't find either "naive" number recorded anywhere. At the recorded speeds, the four candidates for a 4×4 cell take about a quarter of a second, and the nine for a 9×9 cell about forty.

Try It

cd examples/sudoku
go run ./cmd --size 4x4 --ode --analyze

That loads the committed 4×4 net and prints the puzzle, the place / transition / arc counts and a solution check. The --analyze path does not call the solver: runODESimulationOnNet in main.go prints a description of the method and returns. The code that actually integrates the net is the benchmark, from the go-pflow root:

go test -run '^$' -bench Sudoku4x4ODE ./examples/sudoku/cmd

It times the solve and throws the result away — nothing in the repo yet reads solved from a solution or ranks a move. Not re-run for this revision.

Open question

What collector would make the ranking work? "Row 0 is complete" means each digit appears in some cell of the row — an AND over digits of an OR over cells — and a transition's enabling is a plain AND over its inputs, which is how the generator ended up asking for all 16. One shape that fits would put the OR in the arcs: a "1 is in row 0" place fed by each of the row's four _D1_0_c places, and a collector taking one token from each of the four digit places. We haven't built it. Even if it fires, it fixes only the collectors; the digit layer still has no arcs that make a conflicting placement cost anything, so whether the ODE would separate 2 from 4 at (0,1) — or rule out 1 and 3 — is open.

What Changed

2026-10-17. Re-read against examples/sudoku/cmd/model.go, main.go, sudoku_bench_test.go, benchmark_results.txt, the two committed -ode.jsonld nets and solver/ode.go. Digit transitions corrected from 64 to 48 (4×4) and from 729 to 459 (9×9 sample), since the generator makes them only for empty cells. The collector description now says what the arcs say — an input from every history place in the row, column or block — which means it never fires on a legal fill, and solved stays at 0. The ODE section now says the current net scores every candidate 0 rather than ranking them; the 0.31 / 0.28 scores and "conflicts appear as zero-flow transitions" are marked illustrative, and the figure's "block 0 has 3" is corrected to column 1. The tuning table's "max iterations 1000" is gone (the benchmark leaves 100000), the performance table is replaced with the recorded benchmark, and the earlier naive timings and tic-tac-toe 2.1 s → 0.1 s row are dropped as unrecorded. "Try It" now says the --analyze flag doesn't run the solver, and points at the benchmark. Node names follow the code (P0_1, D2_0_1, _D2_0_1).


Data from go-pflow · For the method: Declarative Differential Models

Chapter 7: Constraint Satisfaction of Petri Nets as a Universal Abstraction tells the earlier version of this model and has not yet had these corrections.

Related: Tic-Tac-Toe Model · Knapsack Model

×

Follow on Mastodon