シャンチー(中国象棋)には公開されたパズル集が存在しない。チェスには実戦から掘り出された何百万もの局面が無料で公開されているが、ネットで見つかるシャンチーのパズルは人が創作した終盤の作品であり、それは別物で供給量もはるかに少ない。

そこでマイナーを作った。実戦の棋譜を読み、人が誤った手を見つけ、勝ちにつながる手がちょうど一つだけの局面を残す。現在Mistboardで1,415問のパズルを提供しており、12局面のサンプラーでは手順を一手ずつ再生できる。

以下はその集合から取り出した本日の一問。盤上で手を指してみてほしい。枠内での挑戦はレーティングに影響しないので、失うものは何もない。

ここからは、そうした一問がどう作られるのか、そしてその方法で作った集合がどんな姿になるのかの話だ。

棋譜

棋譜はElephantChessから得ている。同サイトは自分のところの棋譜を匿名化した月次データとしてGPL-3.0で公開している。アマチュアの対局であり、これが重要だ。強豪はパズルの供給源になるほど頻繁に悪手を指さない。すべての出典にライセンス状況が付いており、出典が確認できないパズルは公開されない。

実行ではエンジン時間を費やす前に棋譜リストを固定し、レーティング、持ち時間、結果、手数にわたってサンプリングして、全部が早指しにならないようにしている。開始後は何も追加されず、それが再現性を担保している。

2段階構成と、その分割が得になる理由

この仕組みの経済性はすべて一つの非対称性に依る。安い走査はすべての棋譜のすべての局面に走り、高価な走査はそこを生き残ったものにしか走らない。

安い走査は棋譜を再生し、8手目以降のすべての局面で止まり、60,000ノード(おおよそ探索深さ10から14)でPikafishに上位2手を尋ねる。

実際に指された手がエンジンの最善手に対して250センチポーン以上損しており、かつ残った局面が相手側にとって250以上の勝勢であるとき、その局面は候補になる。互角に落ち着く悪手はパズルではない。見つけるべきものがないからだ。

ある工夫でこの走査は半分になる。手を評価するには局面の前後の値が必要だが、走査を順序どおりに進めれば両方すでに手元にある。指された手の値は次の局面の評価値の符号を反転したものだからだ。

for (let ply = minPly; ply < moveCount; ply += 1) {
  const pre = scans[ply];       // the best that was available
  const post = scans[ply + 1];  // what they left behind, opponent's view
  if (pre === null || post === null) continue;

  if (Math.abs(pre) >= decidedCp) continue;  // already decided: no tactic
  if (post < winCp) continue;                // solver must end up winning

  const playedCp = -post;                    // the move, in their own terms
  const swing = pre - playedCp;
  if (swing < swingCp) continue;             // a mistake, but a small one

  candidates.push({ ply, swingCp: swing, preBestCp: pre, postBestCp: post });
}

すべての棋譜のすべての局面に触れる走査で、1局面あたりの探索が2回ではなく1回になる。この半減が、集合全体の走査を現実的な費用に収めている。1,000局あたり約6コア時間、公開パズル1問あたり約1コア分だ。

次に高価な走査が、各候補を探索深さ20、600,000ノード(10倍の予算)でエンジンにかけ直す。指し手の履歴を持たない裸のFENとして渡す。同じ局面でも文脈がないので、エンジンは直前に行った探索に頼れない。

答えを一つにするもの

解答は一手ずつ組み立てられ、どの手もそれ自体で唯一の最善でなければならない。エンジンの最善応手列をそのまま採るのは通らない。PVは一回の探索でエンジンが気に入った一本の変化に過ぎず、3手目が強制だったかどうかについては何も語らない。別の3手目を見つけて不正解と告げられた解き手は、嘘をつかれたことになる。

一意性もセンチポーンの差ではない。50センチポーン離れた2手はどちらも良く、一方を要求すると正しく選んだ解き手を罰することになる。ある手を唯一の答えにするのは、他のすべての選択肢が誤りであること、つまり勝ちを手放すか、駒得の点で劣る勝ちしか得られないことだ。

function classifySolverMove(best, second) {
  // Mate saturates both centipawns and win%, so mates get their own rule:
  // unique only when this is the strictly fastest forced mate.
  if (mates(best)) {
    if (!second || !mates(second)) return { unique: true, reason: 'fastest-mate' };
    return best.mate < second.mate
      ? { unique: true, reason: 'fastest-mate' }
      : { unique: false, reason: 'mate-not-unique' };
  }

  if (winRate(best.scoreCp) < 0.8) return { unique: false, reason: 'best-not-winning' };
  if (!second) return { unique: true, reason: 'only-move' };

  const gapCp = best.scoreCp - second.scoreCp;
  if (gapCp < 200) return { unique: false, reason: 'near-tie' };

  // The runner-up is wrong if it gives the win away outright...
  if (winRate(second.scoreCp) <= 0.6) return { unique: true, reason: 'runner-up-loses-win' };
  // ...or if it still wins, but wins a whole piece less.
  if (gapCp >= 250) return { unique: true, reason: 'material-gap' };

  return { unique: false, reason: 'alternative-still-good' };
}

失敗時は締める側に倒れる。ゲートが分離できなかったものはすべて破棄され、その理由が候補に記録されるので、あらゆる却下は事後に検証できる。

2段目の検査は、生き残ったものをノード上限なしの探索深さ22で、別プロセスで、1段目の判断を一切知らない状態で監査する。そのうち約6%が持ちこたえない。同じエンジンを異なる予算で2回走らせたときのこの不一致率こそ、2段目を置く理由そのものだ。一つのエンジンの一つの探索深さは、自らの判定についての真理の源ではない。

まだ誰も解いていない手順にレーティングを付ける

検証済みで一意でも、なお悪いパズルになりうる。その分かれ目を決めるのが難易度だ。

素朴なレーティングは詰みまでの深さだが、それでは集合全体で4通りの値しか出ない。実際のレーティングは解答をたどり、局面を見えにくくする要素を採点する。静かな初手は加点、駒取りは減点、回収されない犠打は最大200の加点、そして防御側の応手の数は両方向に効く。取り返しのきかない駒取りは最大の減点で、得られる駒の量に比例する。取られたままの駒は盤上で最も見つけやすいものだからだ。

// Only the solver's first move, and only while the game is still running: a
// capture that MATES also leaves the opponent no legal moves, and reading that
// as "nothing can recapture" would penalise every mating capture in the corpus.
if (index === 0 && captured && state.status.type === 'playing') {
  const recaptures = getLegalMoves(state).filter((reply) => reply.to === move.to);
  if (recaptures.length === 0) freeCaptureCp = MATERIAL_CP[captured.role];
}

この番人の部分は盗む価値がある。素朴な判定は、書こうと思いつくどのテストも通過してしまう形で誤っている。詰みとなる駒取りもまた、相手に合法手を残さないからだ。

ある種類の局面はレーティングを付けずに保留する。解き手がすでに馬1枚以上リードしており、しかも答えが無防備な駒を取ることであるパズルは、どのレーティングでも何も教えない。レーティングが決められるのは、誰にその問題を見せるかだけだ。

集合の実際の姿

サンプラーから3章、それぞれが数字の一つを担っている。

パズルの3分の2は、何も取らない手で始まる。 まず駒取りを見て戦術を探すやり方(ほとんどの人がそうする)では、たいてい盤の間違った3分の1を見ていることになる。次の例は4月の広東対山東の団体戦からで、手順中どちらも一度も駒を取らないまま詰みで終わる。

駒を捨てる手が絡むのは約10分の1だけ。 人が記憶に残すのは犠打の戦術なので、もっと大きな割合だと思っていた。実戦の人間同士の対局では、勝つ手はふつう単なる一手だ。ここでは車が盤を渡ってc1へ行き、f1に踏み込んで取られ、馬がd1で詰める。

そして40%はそもそも詰みで終わらない。 解き手が単に勝勢になる形で終わり、これは詰み型の直感では見落とす類だ。下の勝ちの手は将の一歩で、紅の将がe2からe1へ動き、何も取らず、何も狙わず、3手後の駒交換で紅が650センチポーン良くなる。

   
提供パズル数 1,415
レーティング範囲 1000から2600、479通り
駒を取らない手で始まる 約3分の2
詰み以外で終わる ~40%
駒を捨てる ~10%

結局パズルとは何なのか

勝勢の局面はたいてい勝ちの手が複数あり、それが失格の理由になる。マイナーが見つけるもののうち3分の1はそれだけで消える。パズルとは、答えが一つで、見つけるのに労力を要するだけ深く、1時間後にさらに強いエンジンが同意するだけ安定した局面だ。誤りの10のうち9は条件を満たさない。それが、大きなシャンチーのパズル集がどこにも転がっていない本当の理由だ。供給の問題は悪手を見つけることではなく、そのほとんどがパズルではないことにある。

解いてみる

この集合は無料でアカウントも不要。サンプラーは掘り出した12局面に、それぞれが何を示すかの注記を付けたもの。トレーナーはレーティング付きで1,415問すべてを提供する。