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_AVAILABLE means mandatory capture prevented quiet expansion.
  • PROMOTION_AVAILABLE means at least one legal successor left the no-promotion subspace.
  • TERMINAL means 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.