Skip to content

Mobility and Passed Pawn Contracts

The playground evaluation model ablation (fortemate/dicechess-training#12, #14) tests position-evaluation models against the baseline KcpFeatures (kcp-13). Strategy research highlights three position properties that no previous feature schema exposed: expected wasted rolls (dice faces whose piece type has no move), pawn blockage, and tempo as separate own and opponent mobility counts rather than a single signed difference.

In addition, the endgame motivates a passed-pawn block: pawn promotion in Dice Chess depends on rolling a pawn face, and a passed pawn’s value increases sharply as it nears the back rank.

Following the principle of train == serve, training enrichment and serving inference share the exact engine implementation to prevent drift.

Two primitives in dicechess.engine.search compute the underlying positional features:

Exposes pseudo-legal move counts per moving piece type in dice order (Pawn, Knight, Bishop, Rook, Queen, King):

package dicechess.engine.search
object PieceMobility:
def counts(state: GameState, color: Color): Array[Int]
def count(state: GameState, color: Color, pieceType: PieceType): Int
def total(state: GameState, color: Color): Int

Moves are computed on state.withActiveColor(color) with castling dice present (King and Rook dice in pool), exactly matching how RichFeatures.mobility_diff computes its operands. For every position and color:

∑own_moves_*−∑opp_moves_*=mobility_diff\sum \text{own\_moves\_*} - \sum \text{opp\_moves\_*} = \text{mobility\_diff}

Exposes passed-pawn detection and rank advancement:

package dicechess.engine.search
object PassedPawns:
def count(state: GameState, color: Color): Int
def maxRank(state: GameState, color: Color): Int
def countAndMaxRank(state: GameState, color: Color): (Int, Int)
def bitboard(state: GameState, color: Color): Bitboard
  • Definition: Uses the standard chess definition: a pawn is passed if there are no opposing pawns on the same file or on adjacent files ahead of the pawn along its direction of advance.
  • Max rank advancement: Counted from that side’s own back rank as 0:
    • White pawn on rank r∈[2,7]r \in [2, 7]: advancement is r−1∈[1,6]r - 1 \in [1, 6].
    • Black pawn on rank r∈[2,7]r \in [2, 7]: advancement is 8−r∈[1,6]8 - r \in [1, 6].
    • When a side has no passed pawn, the value is 00.
  • Perspective: Evaluated from the explicit color argument, independent of active color and dice pool.

Reusing detection with consumer-owned rank bonuses

Section titled “Reusing detection with consumer-owned rank bonuses”

PassedPawns is the canonical detector in the engine artifact, not the rules-only artifact. Reuse bitboard(state, color) instead of maintaining a second front-span detector. Its four existing methods retain their behavior and signatures.

Only opposing pawns strictly ahead on the same or neighboring files matter. Friendly pawns, opposing non-pawn pieces, and opposing pawns on the same rank or behind do not disqualify a pawn. Doubled pawns are considered individually and can both be passed. A passed pawn can still have its movement blocked; detection does not test legal moves, king safety, dice availability, or a clear promotion path.

Keep the consumer’s bonus table and evaluation weights. The following adapter is also a compiled example in PassedPawns Scaladoc and is exercised against an independent reference in PassedPawnsContractSpec:

import dicechess.engine.domain.*
import dicechess.engine.search.PassedPawns
def passedPawnBonus(state: GameState, color: Color, bonusByAdvancement: IArray[Int]): Int =
var p = PassedPawns.bitboard(state, color).value
var score = 0
while p != 0L do
val square = java.lang.Long.numberOfTrailingZeros(p)
val rank = square / 8 + 1
val adv = if color.isWhite then rank - 1 else 8 - rank
score += bonusByAdvancement(adv)
p &= p - 1L
score

For pawns on board ranks 2–7, adv is 1–6 from either color’s perspective. Provide at least seven entries: index 0 is unused, and entries 1–6 contain the caller’s existing bonuses. For a six-entry table indexed 0–5, use adv - 1 instead. If the existing table uses absolute board ranks or another convention, translate the index explicitly. Synthetic positions with pawns on promotion/back ranks need a separately defined caller policy; the 1–6 adapter assumes ordinary unpromoted pawn ranks.

The loop uses primitive Long operations without allocating a collection or an iterator per pawn. Allocate the immutable table once outside evaluation. A caller-owned Int => Int may replace the IArray[Int] parameter with the same lookup expression; reuse that function outside the loop as well. Retain the original accumulation type, overflow handling, own/opponent signs, weight multiplication, rounding and normalization order. The Int example assumes the caller’s scores fit in Int; it supplies no default bonuses or weights. maxRank and countAndMaxRank summarize a set and cannot replace a sum of individual rank bonuses when there are multiple passed pawns.

Qualification and downstream adoption checklist

Section titled “Qualification and downstream adoption checklist”

The existing fixtures in PassedPawnsSpec remain in place. PassedPawnsContractSpec compares exact pawn sets and all four public methods against an independent mailbox scan of every candidate and every possible opposing blocker. It exhausts single opposing-pawn pairs across files a–h and ranks 2–7 for both colors, and covers mixed boards, doubled pawns, friendly and non-pawn blockers, explicit perspective, active-color/dice independence and exact set mapping under Symmetry.colorFlip and Symmetry.horizontalMirror. Adapter checks include arbitrary signed, non-monotone tables and weights, every advancement index, doubled pawns and the empty set.

For the later downstream integration:

  • Pin an engine version containing the qualified contract; retain the current evaluator configuration, table values, scale and weights in the consumer.
  • Compare the old detector’s exact square set with PassedPawns.bitboard on public/synthetic fixtures and the consumer’s private fixtures in its own repository. Include both perspectives, edge files, doubled pawns and blockers.
  • Compare per-pawn indices, unweighted sums and final weighted scores using the current consumer parameters and arithmetic. Do not replace a per-pawn sum with the maximum advancement.
  • Report any detector or scoring discrepancy explicitly. Obtain a separate compatibility decision for semantic changes before replacing the old path.
  • Replace only local detection, remove its obsolete masks, and keep bonus lookup and weighting in the consumer. Verify active-color/dice independence and color-flip score parity with the consumer’s rank convention.
  • Run the consumer’s correctness gates. If its evaluation hot path changes, record reproducible fixed-work JMH measurements including work counts and allocations. Keep timed-search and playing-strength research separate.

This qualification adds tests and examples without changing detector code, serving defaults, feature schemas, or downstream evaluators. It does not establish parity with a particular consumer implementation; that comparison belongs to the adoption task. Keep private parameters, artifacts and experiment verdicts in the consumer’s private records.

Extends KcpFeatures (13 columns) with per-piece move counts and normalized PDI:

IndexColumn NameSource / DefinitionRange
0–12p_diff … queen_capture_dangerIdentical to KcpFeatures.extractfloat32
13own_moves_pOwn pseudo-legal pawn moves≥0\ge 0
14own_moves_nOwn pseudo-legal knight moves≥0\ge 0
15own_moves_bOwn pseudo-legal bishop moves≥0\ge 0
16own_moves_rOwn pseudo-legal rook moves≥0\ge 0
17own_moves_qOwn pseudo-legal queen moves≥0\ge 0
18own_moves_kOwn pseudo-legal king moves≥0\ge 0
19–24opp_moves_p … opp_moves_kOpponent pseudo-legal move counts≥0\ge 0
25own_pdiPieceDiversity.count(state, color) / 5f0.0 to 1.0
26opp_pdiPieceDiversity.count(state, opponent) / 5f0.0 to 1.0

Extends kcp-mobility-27-v1 with the passed-pawn block:

IndexColumn NameSource / DefinitionRange
0–26Prefix (27 columns)Identical to KcpMobilityFeatures.extractfloat32
27own_passed_pawnsNumber of passed pawns for color0 to 8
28opp_passed_pawnsNumber of passed pawns for opponent0 to 8
29own_passed_max_rankMost advanced passed pawn rank from back rank0 to 6
30opp_passed_max_rankOpponent most advanced passed pawn rank0 to 6

Both contracts are mover-canonical: evaluating state from color perspective produces the identical vector as evaluating Symmetry.colorFlip(state) from color.opponent perspective. When evaluated for the side to move (state.activeColor), the vector matches that of the color-flipped state evaluated for its own mover.

In Dice Chess, a turn rolls 3 dice from a uniform 6-sided die {P,N,B,R,Q,K}\{P, N, B, R, Q, K\}. If a side has zero pseudo-legal moves for a piece type TT (and therefore zero legal moves), any die face showing TT cannot be used for that type.

For a single die roll, the probability that the rolled piece type has no move is:

P(unusable)=immobile_types6P(\text{unusable}) = \frac{\text{immobile\_types}}{6}

Across the 3 dice rolled in a turn, by linearity of expectation, the expected number of wasted dice is:

E[wasted rolls]=3×immobile_types6=12×immobile_types\mathbb{E}[\text{wasted rolls}] = 3 \times \frac{\text{immobile\_types}}{6} = \frac{1}{2} \times \text{immobile\_types}

The per-piece move counts own_moves_* and opp_moves_* directly expose whether each piece type is mobile (>0>0) or blocked (00).

  • Pawn Blockage: When own_moves_p == 0, friendly pawns are completely locked or absent. In Dice Chess, rolling pawn dice when pawns are blocked forces the player to burn turns or forgo actions.
  • Separate Tempo: Rather than collapsing mobility into a signed scalar (mobility_diff = own - opp), exposing own_moves_* and opp_moves_* separately allows non-linear models (neural nets) to evaluate asymmetries—for example, high mobility for both sides (tactical volatility) versus low mobility for both sides (closed deadlock).

Microbenchmarks on OpenJDK 25 (Temurin 25.0.4) compare extraction overhead across the 5 standard benchmark positions (initial, kiwipete, endgame, castling, promotion):

BenchmarkPositionAverage Time (μs\mu s)
passedPawnsSummaryinitial / kiwipete / endgame / castling / promotion0.004 – 0.012 μs\mu s
pieceMobilityCountsinitial / kiwipete / endgame / castling / promotion0.101 – 0.436 μs\mu s
kcp (13 cols)promotion / endgame / castling / initial / kiwipete79 – 17,664 μs\mu s
kcpMobility27promotion / endgame / castling / initial / kiwipete82 – 17,466 μs\mu s
kcpMobilityPawns31promotion / endgame / castling / initial / kiwipete78 – 17,149 μs\mu s

The 216-outcome DFS search for king/queen capture probabilities in kcp dominates total extraction time (hundreds of microseconds to tens of milliseconds). The additional per-type move counting (∼0.10–0.44 μs\sim 0.10\text{–}0.44\,\mu s) and passed-pawn bitboard probe (∼0.01 μs\sim 0.01\,\mu s) add under 0.01%0.01\% overhead on complex positions (e.g. 0.44 μs0.44\,\mu s on kiwipete’s 17.6 ms17.6\,\text{ms}) and under 0.55%0.55\% on the fastest sparse positions (0.44 μs0.44\,\mu s on promotion’s 79 μs79\,\mu s).