Zobrist ハッシュと差分更新
探索エンジンの置換表(Transposition Table)検索、同一局面の千日手検出、および歩構造や駒割の評価値キャッシュには、局面を固定長の整数へ高確率で一意に写像する Zobrist ハッシュ が用いられる。
rsshogi の Zobrist ハッシュ実装(crates/rsshogi/src/board/zobrist.rs および crates/rsshogi/src/board/position/hash.rs)は、スタック主導の O(1) 巻き戻しと、64 ビット / 128 ビット間の乱数列互換性を備えている。
1. StateHot を唯一の真実の源とする差分更新と O(1) 巻き戻し
Position 構造体自身はハッシュ値のフィールドを持たず、常に StateStack の先頭にある StateHot がハッシュ値の唯一の真実の源(Single Source of Truth) となっている。
管理されるハッシュ値は以下の 4 種類(合計 5 個の ZobristKey)である。
フィールド(StateHot 内) | 構成要素 | 主な用途 |
|---|---|---|
key | board_key ^ hand_key(盤上駒 + 手番 + 両者の持ち駒) | 置換表(TT)のメインキー。 |
board_key | 盤上の駒配置 + 手番(持ち駒を含まない) | 千日手および同一盤面の優等局面と劣等局面の判定。 |
partial_keys.pawn | 盤上の先後の「歩(PAWN)」のみの配置(歩が 0 枚のときは Zobrist::no_pawns()) | 歩構造評価キャッシュ(Pawn Hash Table)のキー。 |
partial_keys.non_pawn_material | [BLACK, WHITE] それぞれの「歩と玉を除く盤上駒」の配置ハッシュ | 駒割および終盤度合いの部分キャッシュ。 |
apply_move32 時の差分 XOR 更新と undo_move32 時の計算ゼロ復元
- 指し手適用時(
apply_move32):直前の局面のStateHotからハッシュ値をコピーし、移動した駒、成った駒、捕獲された駒、変化した持ち駒枚数、および手番ビット(Zobrist::side())に対応する乱数だけを XOR(^=)して新しいStateHotに書き込む。81 マス全体を再走査する必要はない。 - 指し手巻き戻し時(
undo_move32):XOR による逆演算を一切行わず、StateStack::pop()でスタックポインタを 1 つ戻すだけ で 1 手前の全 5 個のハッシュ値が即座に復元される。これによりundo_move32のハッシュ復元コストは完全にゼロとなる。
なお、万が一歩が盤上から 1 枚もなくなった場合に partial_keys.pawn が 0(未初期化エントリと衝突しやすい値)になるのを防ぐため、歩が 0 枚の初期状態には専用の非ゼロ定数 Zobrist::no_pawns() が割り当てられている。
2. 決定論的 PRNG と「1 キーあたり 4 回抽選」による 64 / 128 ビット互換性
Zobrist テーブル(ZOBRIST_TABLE)は、固定シード ZOBRIST_SEED = 20_151_225 を持つ const fn の Xorshift64* 擬似乱数生成器によってコンパイル時に完全生成される。
デフォルトでは ZobristKey = u64(64 ビット)だが、大規模な置換表や長期対局でのハッシュ衝突率を極限まで下げるための hash-128 Cargo feature を有効にすると ZobristKey = u128(128 ビット)に切り替わる。
ここで重要な設計が、hash-128 の有無にかかわらず、1 つの Zobrist キーを生成するたびに必ず Xorshift64* を 4 回進める(next_key_from_four_draws) という規則である。
#![allow(unused)]
fn main() {
const fn next_key_from_four_draws(
d0: u64, // 1 回目の抽選
d1: u64, // 2 回目の抽選
d2: u64, // 3 回目の抽選
d3: u64, // 4 回目の抽選
) -> ZobristKey {
// 1 回目と 2 回目を合成して下位 64 ビット (low) を作る(bit 0 はクリア)
let low = (d0 ^ d1.rotate_left(17)) & !1u64;
#[cfg(feature = "hash-128")]
{
// 3 回目と 4 回目を合成して上位 64 ビット (high) を作る
let high = d2 ^ d3.rotate_left(29);
(low as u128) | ((high as u128) << 64)
}
#[cfg(not(feature = "hash-128"))]
{
let _ = (d2, d3);
low
}
}
}
もし 64 ビットビルドで乱数を 2 回だけ消費し、128 ビットビルドで 4 回消費する実装にしてしまうと、2 個目のキー以降で PRNG の内部状態がずれ、64 ビットビルドのハッシュ値と 128 ビットビルドの下位 64 ビットがまったく別物になってしまう。
常に 4 回ずつ抽選して d0, d1 から下位 64 ビット、d2, d3 から上位 64 ビットを構築することで、hash-128 を有効にしたときの key as u64(下位 64 ビット)が、デフォルトビルドの key と全局面および全テーブルエントリにおいて 1 ビットの狂いもなく完全一致する。
また、手番ハッシュ Zobrist::side() は定数 1(最下位ビットのみが 1)と定義され、盤上駒および持ち駒のすべての乱数キーは最下位ビット(Bit 0)が & !1u64 で 0 にマスクされている。
これにより、局面ハッシュ key の Bit 0 は常に手番パリティのみを表し、駒の移動や持ち駒の増減によって手番ビットが打ち消されることは絶対にない。
3. 72 スロットのフラット持ち駒テーブル(HAND_SLOT_COUNT = 72)
持ち駒の Zobrist テーブル hand: [[ZobristKey; 72]; 2] は、Hand(u32) の各駒種に割り当てられたビット幅と完全に整合する 72 スロットの 1 次元配列として配置されている。
持ち駒種(HandPiece) | ベースオフセット(HAND_SLOT_BASE) | スロット数(HAND_SLOT_LEN) | インデックス範囲 | スロット base + 0 の値 |
|---|---|---|---|---|
歩(PAWN = 0) | 0 | 32(5 bits) | 0..32 | 0(0 枚のときは XOR しても不変) |
香(LANCE = 1) | 32 | 8(3 bits) | 32..40 | 0 |
桂(KNIGHT = 2) | 40 | 8(3 bits) | 40..48 | 0 |
銀(SILVER = 3) | 48 | 8(3 bits) | 48..56 | 0 |
角(BISHOP = 4) | 56 | 4(2 bits) | 56..60 | 0 |
飛(ROOK = 5) | 60 | 4(2 bits) | 60..64 | 0 |
金(GOLD = 6) | 64 | 8(3 bits) | 64..72 | 0 |
各駒種の 0 枚目スロット(HAND_SLOT_BASE[hp] + 0)にはあらかじめ 0 が格納されており、1 枚以上のスロットに独立した乱数キーが格納されている。
この設計には以下の 2 つの利点がある。
- 枚数変更時の更新が 2 回のテーブル参照で済む:持ち駒
hpの枚数がold_countからnew_countに変化したとき、Zobrist::hand(color, hp, old_count) ^ Zobrist::hand(color, hp, new_count)をkeyに XOR するだけでよい。old_count == 0やnew_count == 0の場合でもスロット0の値が0であるため、0 枚判定のif分岐が一切不要となる。 - 不正枚数でも境界外アクセスが起きない:各駒種のスロット数が
Hand(u32)のビットマスク幅(歩は0x1fで最大 31、香/桂/銀/金は0x07で最大 7、角/飛は0x03で最大 3)と一致しているため、マスクで取り出した値をインデックスに加えても常に0..72の範囲内に収まる。