Tablebase primitives
Clatsop keeps notebook models and search storage separate. Board and Turn
validate inputs and provide displays. Large searches can instead use one
integer per state and ordinary Python sets and dictionaries.
Packed states
A packed state occupies 97 bits:
bits 0-31 black pieces
bits 32-63 white pieces
bits 64-95 kings
bit 96 black to move
Use Turn.packed, pack_state(), unpack_state(), and
Turn.from_packed() at API boundaries:
from clatsop import Turn, pack_state, unpack_state
turn = Turn.initial()
state = turn.packed
assert state == pack_state(turn)
assert unpack_state(state) == turn.key
assert Turn.from_packed(state) == turn
expand_packed() validates its input. Once a frontier contains only states
generated by Clatsop, expand_packed_unchecked() can skip repeated invariant
checks. The unchecked method is intentionally explicit: arbitrary integers
must not enter that path.
Status flags
Expansion returns successors and a bit field:
from clatsop import CAPTURE_AVAILABLE, PROMOTION_AVAILABLE, TERMINAL
successors, status = ruleset.expand_packed(
state,
allow_captures=False,
allow_promotions=False,
)
CAPTURE_AVAILABLEmeans mandatory capture prevented quiet expansion.PROMOTION_AVAILABLEmeans at least one legal successor left the no-promotion subspace.TERMINALmeans there is no legal move. A policy-pruned exit is not terminal.
Quiet layers
With twelve men per side and captures and promotions treated as exits, every
expanded move advances exactly one man by one row. The sum of piece progress
therefore increases by exactly one on each move. quiet_ply() returns this
unique layer and verifies side-to-move parity.
Consequently, a state cannot recur in a different quiet round. Deduplication is needed only within the next frontier:
from clatsop import Ruleset, Turn, quiet_ply
ruleset = Ruleset.american()
frontier = {Turn.initial().packed}
for round_number in range(10):
next_frontier = set()
generated = 0
collisions = 0
for state in frontier:
successors, status = ruleset.expand_packed_unchecked(
state,
allow_captures=False,
allow_promotions=False,
)
generated += len(successors)
before = len(next_frontier)
next_frontier.update(successors)
collisions += len(successors) - (len(next_frontier) - before)
assert all(
quiet_ply(Turn.from_packed(candidate)) == round_number + 1
for candidate in next_frontier
)
frontier = next_frontier
Persist completed layers independently rather than retaining one global
in-memory seen set.
Predecessor edges
State identity and graph topology should also remain separate.
expand_packed_edges() returns complete primitive-rule paths:
edges, status = ruleset.expand_packed_edges(state)
for rule_path, successor in edges:
edge_rows.append((predecessor_id, successor, rule_path))
The rule path must remain part of edge identity. Later capture subspaces can contain distinct jump sequences with the same predecessor and successor. Assign state IDs while deduplicating the next layer, then append edge rows to a columnar or on-disk store. Dataframes can be materialized from bounded batches without making dataframe objects part of the hot move-generation loop.