象棋沒有公開的謎題語料庫。西洋棋有數百萬個從實戰對局中挖掘出來、公開且免費的局面;而網路上找得到的象棋謎題多是人工創作的殘局排局,那是另一回事,數量也少得多。

於是我做了一個挖掘器。它讀取實戰對局,找出人們走錯的著法,並保留恰好只有一著能取勝的局面。目前它在 Mistboard 上提供 1,415 道謎題,另有一份十二個局面的精選集可以逐著演練。

以下是今天的題目,直接取自該語料庫。在棋盤上走著法即可;框內的嘗試一律不計分,所以沒有任何得失。

接下來要談的是其中一道題是怎麼產生的,以及這樣建出來的語料庫最後長什麼樣子。

對局來源

對局來自 ElephantChess,該站以 GPL-3.0 授權,把自家網站的對局做成匿名化的每月資料釋出。都是業餘對局,這點很重要:高手失誤的次數不足以構成供給來源。每個來源都標記授權狀態,只要來源未獲確認,發佈流程就會拒絕該謎題。

每一輪執行在花掉任何引擎時間之前,先凍結自己的對局清單,並依等級分、時限、結果與對局長度取樣,免得最後全是快棋。一旦開始執行就不再新增任何對局,這正是它可重現的原因。

兩輪運算,以及拆成兩輪為何划算

整件事的經濟性只在於一個不對稱:便宜的那一輪要跑過每一局的每一個局面,而昂貴的那一輪只跑過關者。

便宜的那一輪重播一局棋,在第 8 著之後的每個局面停下來,以 60,000 節點(大約搜尋深度 10 到 14)向 Pikafish 詢問最佳的兩著。

當實際走出的著法相對於引擎的最佳著法至少損失 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 });
}

每個局面只搜尋一次而不是兩次,而這一輪要碰到每一局的每一個局面。正是這個減半讓掃描整個語料庫變得負擔得起:每一千局大約六個核心小時,換算下來每發佈一道謎題約一個核心分鐘。

接著昂貴的那一輪把每個候選局面重新交給引擎,搜尋深度 20、600,000 節點,預算是前者的十倍,並且只以一個純 FEN 交付,不帶任何著法歷史。同一個局面,沒有上下文,引擎就無法倚賴它剛剛做過的搜尋。

什麼讓它只有一個答案

解答是一著一著建起來的,而每一著本身都必須是唯一的最佳著。直接整條採用引擎的主要變例行不通:主要變例只是引擎在某一次搜尋中偏好的一條路線,它完全沒說第三著是不是被迫的。一個解題者若走出不同的第三著卻被告知錯了,那是被誤導了。

唯一性也不是厘兵差距。相差 50 厘兵的兩著都很好,硬要只取一著會懲罰做出正確選擇的解題者。讓一著成為那個答案的條件是所有替代著法都是錯的:它們把勝勢送掉,或是贏得的物質明顯更少。

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' };
}

它採取失敗即拒絕的原則。凡是這道關卡無法區分的都被丟掉,而理由會記在候選局面上,因此每一次拒絕事後都可以稽核。

第二輪會在另一個行程中以搜尋深度 22、不設節點上限重新稽核過關者,且完全不知道第一輪的判定。其中大約 6% 站不住腳。同一個引擎在不同預算下兩次執行之間的這個分歧率,正是非要有第二輪的理由:單一引擎在單一深度下,並不能作為自己判定結果的真理依據。

為還沒人解過的路線評分

一條路線可以經過驗證、唯一,卻依然是道糟糕的謎題,而決定好壞的是難度。

最天真的評分方式是將死步數,那會讓整個語料庫只剩四種不同的數值。真正的做法是走過整條解答,為那些使局面難以看清的因素計分:安靜的第一著加分,吃子減分,未收回的棄子最多加 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];
}

那個防護判斷才是值得學走的部分:這項檢查的直覺版本是錯的,而且錯的方式能通過你想得到的所有測試,因為將死的吃子同樣會讓對手沒有任何合法應著。

有一類局面是被扣下而不是被評分。如果解題者原本已經領先超過一個馬,而且答案是吃掉某個無防守的子,那麼這道題在任何分數段都教不了什麼,而評分只能決定誰會看到這道題。

這個語料庫最後長什麼樣子

從精選集中挑出三章,各對應其中一個數字。

三分之二的謎題以一著什麼都不吃的著法開始。如果你找戰術時先掃視所有吃子著法,也就是我們多數人的做法,那你大部分時間都在看錯的那三分之一棋盤。在這一題(取自四月廣東對山東的團體賽)中,整條路線上雙方都沒有吃掉任何子,最後仍然成殺:

只有大約十分之一涉及送掉物質。棄子是大家記得住的戰術,所以我原本以為它會佔更大比例。在真實棋手之間的實戰對局裡,取勝的那一著通常就只是一著平常的著法。這裡車橫越棋盤到 c1,再走進 f1 任人吃,接著馬在 d1 成殺:

還有 40% 根本不以將死收尾。它們以解題者單純取得勝勢作結,而這正是以將死為形狀的直覺會漏掉的那一類。下面的取勝著法是帥走一步:紅方的帥從 e2 走到 e1,什麼都不吃,什麼都不威脅,而三個著次之後的兌子讓紅方多出 650 厘兵。

   
已提供的謎題 1,415
分數範圍 1000 到 2600,479 個不同數值
以非吃子著法開局 約三分之二
以將死以外的方式收尾 ~40%
送掉物質 ~10%

謎題到底是什麼

大多數勝勢局面都有好幾著能取勝,而這正是它們被淘汰的原因:挖掘器找到的一切中,有三分之一光是卡在這一點上就沒了。謎題是一個只有一個答案的局面,深得需要下功夫才找得到,穩定到一小時後更強的引擎依然同意。十個失誤裡有九個不合格,而這才是沒人手上有大型象棋謎題語料庫的真正原因:供給問題不在於找出失著,而在於其中幾乎沒有一個是謎題。

來解題

這個語料庫免費,也不需要帳號。精選集是十二個挖掘出來的局面,各附一段說明它示範了什麼;訓練器則提供全部 1,415 道,並且計分。