Rustでつくる詰将棋Solver

f:id:sugyan:20211110195955p:plain

というわけで突然Rustで詰将棋ソルバを作りたくなり、作った。

github.com

現時点ではまだ完成度は低くて6割ほどかな…。

とはいえそこらの素直な詰将棋問題なら普通に解けると思う。 冒頭の画像は看寿賞作品の3手詰「新たなる殺意」を2秒弱で解いたもの。

先行事例

将棋プログラムの多くはC++で書かれていて 最近はRustも増えてきているのかな? しかし「詰将棋を解く」ことに特化しているものはあまり多くはなさそうだった。

なかでもRustで書かれているものはna2hiroさんによるものくらいしか無さそうで、

github.com

これを大いに参考にさせていただいた。

しかしこれはおそらく後述の盤面ハッシュ値を生成するために手元で変更を加えているものを使っているようで、手元ではビルドも出来ない。 また個人的にはsfenではなくCSAや他の形式を入出力に使いたいなどの要望もあり、そういった問題も解決しつつ自分の理解も深めるために自作してみることにした。

df-pnアルゴリズム

過去に、Goでも詰将棋ソルバを書こうとしてdf-pnアルゴリズムを使った探索プログラムを実装していた。今回はこれのリメイクということになる。

memo.sugyan.com

df-pnについては上記記事でも触れているが 他にも詳しい記事があるので貼っておきます

komorinfo.com

qhapaq.hatenablog.com

Rustにおける将棋ライブラリ

盤面の状態保持や合法手生成のために、何らかのライブラリが必要になる。

Goで作ったときは愚直にif文for文まみれで自作して使っていて、当然ながら速度も出なかった。 RustではBitboardベースの優れたライブラリが既に作られ公開されているので、これを使うことにした。

github.com

実装に必要なもの

さて、df-pnアルゴリズムは簡単にいうと「root node (初期盤面) から合法手で辿れる child nodes についてそれぞれ(証明数, 反証数) のペアを計算し保存し、それらの値を元に最良な(詰/不詰を判定しやすそうな) child node を優先的に選択して反復深化し探索していく」というもの。

玉方と攻方でそれぞれ AND/OR Tree を形成して詰か不詰を判定することにはなるのだけど、実装においては木構造を作る必要はない

盤面は各合法手を選んだ際に状態遷移して探索から戻ってきたら盤面も戻すことが出来れば良いだけで、重要なのは各盤面の状態における(証明数, 反証数)を保存・取得する方法さえあれば良い、ということ。

一般的には盤面の状態を何らかの方法でハッシュした値を計算し、そのハッシュ値によってテーブルに格納する、ということになる。

よって、

  • 盤面の状態を合法手で進めたり戻したりしつつ 各盤面の状態における一意な値を得ることが出来る
  • 各状態から得られる値を元に (証明数, 反証数)のペアを保存・取得することが出来る

というものがあれば良い。

実装

ということでここからはRustでの実装の話。

Trait

必要なもの、と前述した通りに2つの Trait を用意することにした。 証明数・反証数に関してはとりあえず u32 にしているが 一応変えやすいようにtype aliasで U としている。

use shogi::{Bitboard, Color, Move, MoveError, Piece, PieceType, Square};
use std::hash::Hash;

type U = u32;
pub const INF: U = U::MAX;

pub trait HashPosition {
    type T: Eq + Hash + Copy;
    fn hand(&self, p: Piece) -> u8;
    fn in_check(&self, color: Color) -> bool;
    fn make_move(&mut self, m: Move) -> Result<(), MoveError>;
    fn move_candidates(&self, sq: Square, p: Piece) -> Bitboard;
    fn piece_at(&self, sq: Square) -> &Option<Piece>;
    fn player_bb(&self, c: Color) -> &Bitboard;
    fn side_to_move(&self) -> Color;
    fn unmake_move(&mut self) -> Result<(), MoveError>;

    fn current_hash(&self) -> Self::T;
}

pub trait Table {
    type T;
    fn look_up_hash(&self, key: &Self::T) -> (U, U);
    fn put_in_hash(&mut self, key: Self::T, value: (U, U));

    fn len(&self) -> usize;
    fn is_empty(&self) -> bool;
}

HashPosition traitは基本的に shogi-rsshogi::Position をwrapして使うことを想定して shogi::Position で使うメソッドをほぼそのまま羅列。そこに current_hash(&self) を追加して盤面の状態からハッシュ値を取得できるようにした。

Table はまぁ値を格納したり取り出したりするだけ。途中で状態確認したりtestで使ったりすることもあるかと思い len(&self)is_empty(&self) も一応用意しておいた。

それぞれ Associated type を持つようにしていて、繋ぎこむときに利用している。

Implementations

最初から struct にせず Trait にしたのは、複数の実装方法が考えられたから。

まず Position のハッシュ方法としては、最も簡単に考えられるのが 盤面の状態を表すそれぞれの値をもとに std::hash::Hasher traitを実装したものを使って u64 の値を作る、というもの。盤面各マスの各駒や手番などを何らかの基準で数値化してしまえば計算することができる。

その他に挙げられるのが、Zobrist hashing を使う方法。将棋やチェスなどでは手番ごとの遷移はせいぜい駒が一つ動くだけなので その部分の差分だけ更新して新しいハッシュ値を作る方が効率が良い、という考えのもの。各マスにおける各駒に乱数で値を割り振っておいて、駒が移動した際には移動元と移動先の変化を XOR をとることで高速に計算できる。

Table については 最も簡単なのが std::collections::HashMap に格納する方法。これは文句なしに簡単。

より高速に処理できそうな方法として Vec に格納してしまう、という方法。 Zobrist hashing で usize の値を返すようにして その値をmaskしたものをindexとして利用するという方法が考えられる。

ということで、今回はそれぞれ2つの実装を用意してみた。

  • HashPosition の実装として DefaultHashPositionZobristHashPosition
  • Table の実装として HashMapTableVecTable

DefaultHashPositionPosition をwrapしつつ std::hash::Hash Traitを実装し、std::collections::hash_map::DefaultHasher を使ってハッシュ値を計算している。

use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

pub struct DefaultHashPosition {
    pos: Position,
}

...

impl HashPosition for DefaultHashPosition {
    type T = u64;

...

    fn current_hash(&self) -> u64 {
        let mut s = DefaultHasher::new();
        self.hash(&mut s);
        s.finish()
    }
}

impl Hash for DefaultHashPosition {
    fn hash<H: Hasher>(&self, state: &mut H) {
        Square::iter().for_each(|sq| {
            self.pos.piece_at(sq).map_or(28, p8).hash(state);
        });
        PieceType::iter().for_each(|piece_type| {
            Color::iter().for_each(|color| self.pos.hand(Piece { piece_type, color }).hash(state))
        });
        match self.pos.side_to_move() {
            Color::Black => 0.hash(state),
            Color::White => 1.hash(state),
        };
    }
}

fn p8(p: Piece) -> u8 {
    ...
}

一方、 ZobristHashPositionBitXorAssign ができ Standard: Distribution<T> の定義できるいくつかの数値型を使えるようGeneric Data Typesで定義している。 make_move() が成功したときのみ 最新のhashを元にMoveに関わる部分だけXORを取って新しいhashを計算する。

use rand::distributions::{Distribution, Standard};
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};

pub struct ZobristHashPosition<T> {
    pos: Position,
    table_board: [[[T; 2]; 14]; 81],
    table_hand: [[[T; 19]; 2]; 14],
    table_turn: [T; 2],
    hash_history: Vec<T>,
}

impl<T> ZobristHashPosition<T>
where
    T: Default + Copy + BitXorAssign,
    Standard: Distribution<T>,
{
    pub fn new(pos: Position) -> Self {
        // init table
        ...
        // 初期hash値だけは全マス全持駒から計算する
        let hash = ...
        Self {
            pos,
            table_board,
            table_hand,
            table_turn,
            hash_history: vec![hash],
        }
    }
}

impl<V> HashPosition for ZobristHashPosition<V>
where
    V: Copy + Eq + Hash + BitXorAssign,
{
    type T = V;

    ...

    fn make_move(&mut self, m: Move) -> Result<(), MoveError> {
        match self.pos.make_move(m) {
            Ok(_) => {
                let mut h = self.current_hash();

                h ^= ...  // mに対応する部分だけXORとる

                self.hash_history.push(h);
                Ok(())
            }
            Err(e) => Err(e),
        }
    }
    fn unmake_move(&mut self) -> Result<(), MoveError> {
        match self.pos.unmake_move() {
            Ok(_) => {
                self.hash_history.pop();
                Ok(())
            }
            Err(e) => Err(e),
        }
    }

    fn current_hash(&self) -> V {
        *self.hash_history.last().expect("latest hash has not found")
    }
}

HashMapTableVecTable は以下のような感じ。 HashMapTableEq + Hash の任意の型をキーにでき、 VecTableusize のみ受け付けて key値にmaskをかけた値で Vec の内容にアクセスする。 占有率が上がってきたら古いエントリを捨てるなどの処理も入れるべきかもしれないが、ここでは特に何もしていない。

use std::collections::HashMap;

pub struct HashMapTable<T>
where
    T: Eq + Hash,
{
    table: HashMap<T, (U, U)>,
}

impl<T> HashMapTable<T>
where
    T: Eq + Hash,
{
    pub fn new() -> Self {
        Self {
            table: HashMap::new(),
        }
    }
}


pub struct VecTable {
    table: Vec<(U, U)>,
    mask: usize,
    len: usize,
}

impl VecTable {
    pub fn new(bits: u32) -> Self {
        Self {
            table: vec![(1, 1); 1 << bits],
            mask: (1 << bits) - 1,
            len: 0,
        }
    }
}

impl Table for VecTable {
    type T = usize;
    fn look_up_hash(&self, key: &Self::T) -> (U, U) {
        self.table[*key & self.mask]
    }
    fn put_in_hash(&mut self, key: Self::T, value: (U, U)) {
        if self.look_up_hash(&key) == (1, 1) {
            self.len += 1;
        }
        self.table[key & self.mask] = value;
    }
    fn len(&self) -> usize {
        self.len
    }
    fn is_empty(&self) -> bool {
        self.len == 0
    }
}

Solver

で、こうして用意した HashPositionTable を使って Solver を作る。

pub struct Solver<P, T> {
    pub pos: P,
    pub table: T,
}

impl<P, T> Solver<P, T>
where
    P: HashPosition,
    T: Table<T = P::T>,
{
    pub fn new(pos: P, table: T) -> Self {
        Self { pos, table }
    }
    pub fn dfpn(&mut self) {
        ...
    }
}

HashPositionTable のそれぞれの Associated type T が一致することが求められるので、以下のような組み合わせで作ることが出来る

    let p = DefaultHashPosition::new(pos);
    let t = HashMapTable::new();
    let mut solver = Solver::new(p, t);

DefaultHashPositionT = u64 なので自動的に u64HashMap のkeyに使われる

    let p = ZobristHashPosition::<u64>::new(pos);
    let t = HashMapTable::new();
    let mut solver = Solver::new(p, t);

ZobristHashPosition で指定可能な任意の型が使える

    let p = ZobristHashPosition::new(pos);
    let t = VecTable::new(20);
    let mut solver = Solver::new(p, t);

VecTableT = usize なので自動的に usizeZobristHashPosition になる

それぞれ速度に影響でたり hash計算方法や格納方法によって衝突が起きる可能性が違ってきたりするはず。 解かせたい問題によって実装を切り替えるなどもアリなのかもしれない。

df-pn

で、この Solver がdf-pnアルゴリズムで探索していく。 このコア部分だけだと100行ちょっとしか必要ない。

impl<P, T> Solver<P, T>
where
    P: HashPosition,
    T: Table<T = P::T>,
{
    ...

    pub fn dfpn(&mut self) {
        let hash = self.pos.current_hash();
        // ルートでの反復深化
        let (pn, dn) = self.mid(hash, &(INF - 1, INF - 1));
        if pn != INF && dn != INF {
            self.mid(hash, &(INF, INF));
        }
    }
    // ノードの展開
    fn mid(&mut self, hash: P::T, pd: &(U, U)) -> (U, U) {
        // 1. ハッシュを引く
        ...
        // 2. 合法手の生成
        let children = generate_legal_moves(&mut self.pos);
        // 3. ハッシュによるサイクル回避
        match self.pos.side_to_move() {
            Color::Black => self.table.put_in_hash(hash, (pd.0, pd.1)),
            Color::White => self.table.put_in_hash(hash, (pd.1, pd.0)),
        }
        // 4. 多重反復深化
        loop {
            ...

            let (best, ...) = self.select_child(&children);
            let phi_n_c = ...
            let delta_n_c = ...
            let (m, h) = best.expect("best move");
            self.pos.make_move(m).expect("failed to make move");
            match self.pos.side_to_move() {
                Color::Black => self.mid(h, &(phi_n_c, delta_n_c)),
                Color::White => self.mid(h, &(delta_n_c, phi_n_c)),
            };
            self.pos.unmake_move().expect("failed to unmake move");
        }
    }
    // 子ノードの選択
    fn select_child(&mut self, children: &[(Move, P::T)]) -> (Option<(Move, P::T)>, ...) {
        ...
    }
}


pub fn generate_legal_moves<P>(pos: &mut P) -> Vec<(Move, P::T)>
where
    P: HashPosition,
{
    ...
}

合法手の生成によって child nodes を展開していくことになるが、その時点でその遷移後のハッシュ値が計算できるので generate_legal_movesMove とそれが適用された後のハッシュ値のペアを返すようにして使い回すようにしている。

generate legal moves

ここは HashPosition さえあれば計算できるので Table は必要ない。

通常の Move::Normal については単純で、 player_bb() で盤上の自陣駒が列挙できて そこから move_candidates() で移動先の候補を列挙できるので、そこからのすべての組み合わせで一旦 make_move() を実行してみる。その結果、

  • 攻方の場合は 動いた後が玉方に対する王手になっている
  • 玉方の場合は 動いた後が自陣の王手が外れている

という条件を満たしていれば legal move として選択されることになる。

pub fn generate_legal_moves<P>(pos: &mut P) -> Vec<(Move, P::T)>
where
    P: HashPosition,
{
    let mut children = Vec::new();
    // normal moves
    for from in *pos.player_bb(pos.side_to_move()) {
        if let Some(p) = *pos.piece_at(from) {
            for to in pos.move_candidates(from, p) {
                for promote in [true, false] {
                    let m = Move::Normal { from, to, promote };
                    if let Ok(h) = try_legal_move(pos, m) {
                        children.push((m, h));
                    }
                }
            }
        }
    }
    
    ...

    children
}

fn try_legal_move<P>(pos: &mut P, m: Move) -> Result<P::T, MoveError>
where
    P: HashPosition,
{
    match pos.make_move(m) {
        Ok(_) => {
            let mut hash = None;
            if pos.side_to_move() == Color::Black || pos.in_check(Color::White) {
                hash = Some(pos.current_hash());
            }
            pos.unmake_move().expect("failed to unmake move");
            if let Some(h) = hash {
                Ok(h)
            } else {
                Err(MoveError::Inconsistent("Not legal move for tsumeshogi"))
            }
        }
        Err(e) => Err(e),
    }
}

Move::Drop についてはちょっと面倒で、盤上すべて眺めて空いているマスすべてに対して持駒に持っているものを置いてみる、というのが簡単に列挙はできるのだけど、例えば攻方は相手玉に届かない位置に打つのはすべて無意味だし 玉方は飛び駒以外で王手されている際に駒を打つ手は有り得ないので、そういったものは事前に除外しておかないとかなり非効率な探索になってしまう。

ともかく shogi::Position のinterface的に、合法手の判定は「何らかの Movemake_move() してみて それが Err を返さなかったら合法」と判定するのが基本になる。明らかに非合法な手は事前に除外した方が勿論良いが、処理の先頭で判定しているものもあるので二度手間にならない程度であれば良いようには思える。

benchmark

とりあえずここまででdf-pnアルゴリズムによる探索が動くので、実装による速度の違いを調べてみた。 nightly で使える cargo bench

  • DefaultHashPosition + HashMapTable
  • ZobristHashPosition + HashMapTable
  • ZobristHashPosition + VecTable

のそれぞれの組み合わせで Solver を作り 幾つかの入力に対して探索を実行。

% cargo +nightly bench
    Finished bench [optimized] target(s) in 0.13s
     Running unittests (target/release/deps/tsumeshogi_solver-9e8e57cabb9c5863)

running 1 test
test tests::test_solve ... ignored

test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests (target/release/deps/bench-2ca32c22a0dba4e1)

running 3 tests
test bench_default_hashmap ... bench:  11,456,869 ns/iter (+/- 681,707)
test bench_zobrist_hashmap ... bench:  11,205,930 ns/iter (+/- 674,396)
test bench_zobrist_vec     ... bench:  11,280,294 ns/iter (+/- 749,866)

test result: ok. 0 passed; 0 failed; 0 ignored; 3 measured; 0 filtered out; finished in 10.41s

どれも大して変わらなかった!!

おそらく現状 generate_legal_moves のところが遥かに支配的で、ここが大きなボトルネックになっている限りはハッシュの計算や格納方法での差異は殆ど出てこない、のかな…。

もうちょっとprofilingとか試してしっかり見直したいところ。

最適解の導出

ところで solver.dfpn() の探索処理が完了したところで、詰将棋の「解答」が出てくるわけではない。 手元に残るのは root から探索した各盤面に対する (証明数(以下pn), 反証数(以下dn)) が記録された Table があるだけなので、ここからそれらの値を元に詰みの経路を見つける必要がある。

root の (pn, dn)(0, INF) になっていた場合に、その root が「詰み」であると判明したことになる。その child nodes には今度は (INF, 0) になっているものが存在しているはず。その child ではまた (0, INF) 、… と辿るたびに AND/OR が反転するが ともかく正しく詰みを見つけられた場合はそういった node を見つけられるので、葉(それ以降は合法手が無い、つまり詰み)までDFSで辿っていけば詰みの手順を列挙することが出来る。

fn solve(pos: Position) {
    let mut solver = Solver::new(...);
    solver.dfpn();

    let mut answers = Vec::new();
    search_all_mates(&mut solver, &mut Vec::new(), &mut answers);
}

fn search_all_mates<P, T>(
    s: &mut Solver<P, T>,
    moves: &mut Vec<Move>,
    answers: &mut Vec<Vec<Move>>,
) where
    P: HashPosition,
    T: Table<T = P::T>,
{
    let mut leaf = true;
    for &(m, h) in &generate_legal_moves(&mut s.pos) {
        let pd = s.table.look_up_hash(&h);
        if (s.pos.side_to_move() == Color::Black && pd == (INF, 0))
            || (s.pos.side_to_move() == Color::White && pd == (0, INF))
        {
            leaf = false;
            moves.push(m);
            s.pos.make_move(m).expect("failed to make move");
            search_all_mates(s, moves, answers);
            s.pos.unmake_move().expect("failed to unmake move");
            moves.pop();
        }
    }
    if leaf {
        answers.push(moves.clone())
    }
}

これによって得られる answersdfpn() の探索で見つけることができた詰み手順だが、最短であるとは限らない。このへんは難しいところで 余詰がある場合はまた他の手順で詰むかどうかも判定する必要がある…。ここはまだ実装できていない。

ただ「攻方のある手で詰む」というのは見えているので、その中での玉方の最善応手を見つけることは出来そう。玉方は最長で駒を余らせない応手を選ぶのが正解なので、列挙された answers の中から「詰みまでの手数が最長で かつ詰んだ後の攻方の持駒が少ない(無い)もの」を解として選ぶことが出来る。

また面倒なのが「無駄合駒」で、単純に手数の長いものを選択しようとするとこれに引っ掛かる。今回は

  1. 玉方が合駒として打った駒が後に取られて
  2. 最終的に詰んだときにそれが攻方の持駒に入っている

という場合に無駄合駒だったとみなして候補から外すようにした。合駒をせずに別の方法で王手回避した場合は必ず探索しているはずなので、単純に除外するだけで良いはず…。

これで、余詰の無い問題に対しては「玉方が最善の応手をして駒の余らない最長の手順」を選択できるようになっている。と思う。

残課題

と、ここまでは上手く解けた例の話で、まだまだ上手く解けていないケースが存在しているのを把握している。 例えば以下のような問題で、

f:id:sugyan:20211111001858p:plain

(出典: https://www.shogi.or.jp/tsume_shogi/everyday/20211183_1.html)

正解手順は ▲3四飛 △同玉 ▲4四飛成 なのだけど、dfpn() で探索していると ▲4四飛成 △3二玉 ▲4一竜 △3三玉 ▲4四竜 △3二玉 ... と無限に追いかけっこが発生して詰まないループを検出し、結果として root の (pn, dn)(1, INF) という値になる。正解の詰み手順である ▲3四飛 は探索されずに終わってしまている。

元の論文では GHI (Graph History Interaction) 問題を回避するために閾値を調整して二回探索する手法で解決しているように書いてあるが、その通りに実装しているつもりなのだけど上手くいっていないようだ…。 ここはもうちょっと深く追って調べてみる必要がある。

その他にも 上記のような連続王手の千日手になる手順に辿り着く可能性がある問題に対して うまく探索が完了していないケースがありそうだ。

やねうら王公式からクリスマスプレゼントに詰将棋500万問を謹呈 の問題を食わせて様々なエッジケースを拾えるかな、と思ったが まずそもそも先手から攻める実装しか考えていなくて「後手番から始まって先手玉を詰ませる」というケースを考えていなかった。そこも対応できるように直す必要がある。

あとは前述した通り余詰のチェックもまだ出来ていないので、探索深さ上限を指定して(?)別の手順を探索できるようにはしておきたい。

優越関係などを利用することでもっと長手数のものも解けるようになるだろうか。性能限界と改善の可能性はもう少し調べたい。 shogi-rs を変更してチューニングしてもっと早くなるだろうか?とか。

その他にも入出力の形式をもっと選べるようにconverterを作っておきたいと思っている。