Nim — optimal play via the Sprague-Grundy nim-sum
In normal-play Nim a position is a loss for the player to move iff the XOR of all heap sizes is 0. nim_sum() computes it; best_move() finds a winning (heap, take) move when one exists, else surrenders. Run: soma run nim.cell --signal demo Test: soma test nim.cell
A Soma program from the games part of the verified corpus: 75 lines, cells Nim, NimTests. It passes soma check with soma 3.2.4.
How it is built
The cell Nim declares 4 signals in its face: nim_sum(heaps: List) -> Int, is_losing(heaps: List) -> Bool, best_move(heaps: List) -> Map and demo() -> Map. Handlers: nim_sum (line 20), is_losing (line 29), best_move (line 35) and demo (line 48).
The file also holds 1 test cell (NimTests) with 8 assertions, run by soma test.
The source
soma.toml
[package]
name = "games-corpus"
version = "0.1.0"
description = "Training corpus: games & simulations"
# The turn/phase machine is intentionally cyclic (a game loops through
# phases each round) with a `* -> over` escape, so we verify only that no
# reachable state deadlocks — liveness to a terminal is not the contract here.
[verify]
deadlock_free = truenim.cell
// ============================================================================
// Nim — optimal play via the Sprague-Grundy nim-sum
// ============================================================================
//
// In normal-play Nim a position is a loss for the player to move iff the XOR
// of all heap sizes is 0. nim_sum() computes it; best_move() finds a winning
// (heap, take) move when one exists, else surrenders.
//
// Run: soma run nim.cell --signal demo
// Test: soma test nim.cell
cell Nim {
face {
signal nim_sum(heaps: List) -> Int
signal is_losing(heaps: List) -> Bool
signal best_move(heaps: List) -> Map
signal demo() -> Map
}
on nim_sum(heaps: List) {
let x = 0
for h in heaps {
x = bxor(x, h)
}
return x
}
// A position with nim-sum 0 is a loss for the player about to move.
on is_losing(heaps: List) {
return nim_sum(heaps) == 0
}
// Find a heap whose target size (heap XOR nim_sum) is smaller, then take
// down to it. Returns heap=-1 when the position is already lost.
on best_move(heaps: List) {
let s = nim_sum(heaps)
if s == 0 { return map("heap", -1, "take", 0) }
let result = map("heap", -1, "take", 0)
for i in range(0, len(heaps)) {
let target = bxor(heaps[i], s)
if target < heaps[i] && result.heap == -1 {
result = map("heap", i, "take", heaps[i] - target)
}
}
return result
}
on demo() {
// 3,4,5 → nim-sum 2, winning move exists
return map(
"nim_sum", nim_sum([3, 4, 5]),
"move", best_move([3, 4, 5])
)
}
}
cell test NimTests {
rules {
// 1^2^3 = 0 → losing position
assert nim_sum([1, 2, 3]) == 0
assert is_losing([1, 2, 3]) == true
// 3^4^5 = 2 → winning
assert nim_sum([3, 4, 5]) == 2
assert is_losing([3, 4, 5]) == false
// the winning move turns it into a 0-sum position for the opponent
// take from heap, leaving nim-sum 0
assert best_move([3, 4, 5]).heap == 0
assert best_move([3, 4, 5]).take == 2
// applying the move yields a losing position [1,4,5]
assert nim_sum([1, 4, 5]) == 0
// a lost position offers no move
assert best_move([1, 2, 3]).heap == -1
}
}What the toolchain says
$ soma check nim.cell
✓ All checks passed.$ soma test nim.cell
test NimTests ...
✓ assert nim_sum([1, 2, 3]) == 0
✓ assert is_losing([1, 2, 3]) == true
✓ assert nim_sum([3, 4, 5]) == 2
✓ assert is_losing([3, 4, 5]) == false
✓ assert best_move([3, 4, 5]).heap == 0
✓ assert best_move([3, 4, 5]).take == 2
✓ assert nim_sum([1, 4, 5]) == 0
✓ assert best_move([1, 2, 3]).heap == -1
8 tests: 8 passed, 0 failed$ soma verify nim.cell
Verifying nim.cell...
No state machine in this program: nothing to prove beyond `soma check` (invariants and lifecycles are what verify proves).
VERIFY OK (vacuous — no state machine)