From ab5a437c1bdf0aa7dc449b09931505416edf4343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Sun, 31 May 2026 15:38:09 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20AI=20=E5=8D=87=E7=BA=A7=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92=20(9=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-05-31-gobang-ai-upgrade-plan.md | 1556 +++++++++++++++++ 1 file changed, 1556 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-gobang-ai-upgrade-plan.md diff --git a/docs/superpowers/plans/2026-05-31-gobang-ai-upgrade-plan.md b/docs/superpowers/plans/2026-05-31-gobang-ai-upgrade-plan.md new file mode 100644 index 0000000..a2a2659 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-gobang-ai-upgrade-plan.md @@ -0,0 +1,1556 @@ +# Gobang AI 升级实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 Alpha-Beta AI 升级为专业级:迭代加深、置换表、组合棋形、Killer启发、VCF/VCT、开局库。 + +**Architecture:** 6 个新模块逐步叠加到现有 AI 框架上,Board 先行支持 Zobrist 哈希,置换表+killer 增强搜索,组合棋形+位置权重改善评估,VCF/VCT 独立搜索,开局库预处理。最后重写 search.rs 串联全部组件。 + +**Tech Stack:** Rust, fxhash (快速哈希), rand (开局随机变招) + +--- + +## 文件变更总览 + +| 文件 | 操作 | 内容 | +|------|------|------| +| `core/Cargo.toml` | 改 | +rand, +fxhash | +| `core/src/types.rs` | 改 | ZobristHash 类型 | +| `core/src/board.rs` | 改 | zobrist_hash 字段, place/undo 增量更新, 测试 | +| `core/src/ai/trans_table.rs` | 新建 | TTEntry, TransTable, Zobrist 初始化, 测试 | +| `core/src/ai/killer.rs` | 新建 | KillerTable, 2-slot/depth, 测试 | +| `core/src/ai/evaluate.rs` | 重写 | 组合棋形 + 位置权重, 测试 | +| `core/src/ai/opening.rs` | 新建 | OpeningBook, 50 定式 load, lookup, 测试 | +| `core/src/ai/vcf.rs` | 新建 | vcf_search/vct_search, 测试 | +| `core/src/ai/search.rs` | 重写 | 迭代加深 + TT + killer + evaluate + opening | +| `core/src/ai/mod.rs` | 改 | 公开新模块 | +| `gui/src/commands.rs` | 改 | new_game 适配 | + +--- + +### Task 1: Board Zobrist 哈希增量更新 + +**Files:** +- Modify: `core/src/types.rs` +- Modify: `core/src/board.rs` + +- [ ] **Step 1: 添加 ZobristHash 类型和全局表** + +在 `core/src/types.rs` 末尾添加: + +```rust +/// Zobrist 哈希值 +pub type ZobristHash = u64; + +/// 全局 Zobrist 随机表(pub 供 ai 模块使用) +pub fn init_zobrist_table(board_size: usize) -> Vec> { + use std::collections::hash_map::RandomState; + use std::hash::BuildHasher; + let rng = RandomState::new(); + let mut table = Vec::with_capacity(board_size); + for x in 0..board_size { + let mut row = Vec::with_capacity(board_size); + for y in 0..board_size { + row.push([rng.hash_one((x, y, 0)), rng.hash_one((x, y, 1))]); + } + table.push(row); + } + table +} +``` + +- [ ] **Step 2: 在 Board struct 添加 hash 字段和方法** + +在 `core/src/board.rs` 的 `Board` struct 中添加 `hash` 字段(放在 `current_turn` 之后): + +```rust +pub zobrist_hash: ZobristHash, +``` + +修改 `Board::new` 初始化: + +```rust +zobrist_hash: 0, +``` + +添加方法: + +```rust +/// 获取当前 Zobrist 哈希 +pub fn hash(&self) -> ZobristHash { + self.zobrist_hash +} +``` + +修改 `place` 方法,在 `new_board.cells[pos.x][pos.y] = ...` 之后、history push 之前添加: + +```rust +let color_idx = match color { Color::Black => 0, Color::White => 1 }; +let zobrist = crate::types::init_zobrist_table(self.size); +new_board.zobrist_hash ^= zobrist[pos.x][pos.y][color_idx]; +``` + +修改 `undo` 方法,在 `new_board.cells[...] = CellState::Empty` 之后添加: + +```rust +let last_color_idx = match last_move.color { Color::Black => 0, Color::White => 1 }; +let zobrist = crate::types::init_zobrist_table(self.size); +new_board.zobrist_hash ^= zobrist[last_move.position.x][last_move.position.y][last_color_idx]; +``` + +- [ ] **Step 3: 写 Zobrist 哈希测试** + +在 `board.rs` 测试模块中添加: + +```rust +#[test] +fn test_zobrist_hash_changes_on_place() { + let board = Board::new(15); + let h1 = board.hash(); + let board2 = board.place(Position::new(7, 7), Color::Black).unwrap(); + assert_ne!(h1, board2.hash()); +} + +#[test] +fn test_zobrist_hash_restores_on_undo() { + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let h1 = board.hash(); + let board = board.place(Position::new(7, 8), Color::White).unwrap(); + assert_ne!(h1, board.hash()); + let board = board.undo().unwrap(); + assert_eq!(h1, board.hash()); +} + +#[test] +fn test_zobrist_hash_symmetry() { + // (7,7)黑棋 和 (7,8)黑棋 的哈希不同 + let board = Board::new(15); + let b1 = board.place(Position::new(7, 7), Color::Black).unwrap(); + let b2 = board.place(Position::new(7, 8), Color::Black).unwrap(); + assert_ne!(b1.hash(), b2.hash()); +} +``` + +- [ ] **Step 4: 运行测试并提交** + +```bash +cargo test -p gobang-core +git add core/src/types.rs core/src/board.rs +git commit -m "feat: Board 新增 Zobrist 哈希增量更新 + 测试" +``` + +--- + +### Task 2: 置换表 TransTable + +**Files:** +- Create: `core/src/ai/trans_table.rs` +- Modify: `core/src/ai/mod.rs` +- Modify: `core/Cargo.toml` (+fxhash) + +- [ ] **Step 1: 添加依赖** + +在 `core/Cargo.toml` 添加: + +```toml +fxhash = "0.2" +``` + +- [ ] **Step 2: 创建 trans_table.rs** + +```rust +use crate::types::{Position, ZobristHash}; + +const TT_SIZE: usize = 1 << 20; // 约 100 万条目 +const TT_MASK: usize = TT_SIZE - 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoundType { + Exact, + LowerBound, + UpperBound, +} + +#[derive(Debug, Clone)] +pub struct TTEntry { + pub hash: ZobristHash, // 完整 64 位哈希(防冲突) + pub depth: u8, + pub score: i32, + pub bound: BoundType, + pub best_move: Option, +} + +pub struct TransTable { + entries: Vec>, +} + +impl TransTable { + pub fn new() -> Self { + Self { + entries: vec![None; TT_SIZE], + } + } + + pub fn probe(&self, hash: ZobristHash, depth: u8) -> Option<&TTEntry> { + let idx = (hash as usize) & TT_MASK; + self.entries[idx].as_ref().filter(|e| e.hash == hash && e.depth >= depth) + } + + pub fn store(&mut self, hash: ZobristHash, depth: u8, score: i32, bound: BoundType, best_move: Option) { + let idx = (hash as usize) & TT_MASK; + // 深度优先替换 + let should_replace = match &self.entries[idx] { + None => true, + Some(old) => depth >= old.depth, + }; + if should_replace { + self.entries[idx] = Some(TTEntry { hash, depth, score, bound, best_move }); + } + } + + pub fn clear(&mut self) { + self.entries.fill(None); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_store_and_probe() { + let mut tt = TransTable::new(); + tt.store(12345, 3, 100, BoundType::Exact, Some(Position::new(7, 7))); + let entry = tt.probe(12345, 2).unwrap(); + assert_eq!(entry.score, 100); + assert!(entry.best_move.is_some()); + } + + #[test] + fn test_probe_rejects_lower_depth() { + let mut tt = TransTable::new(); + tt.store(42, 5, 200, BoundType::Exact, None); + // depth 5 满足 depth >= 4 的查询 + assert!(tt.probe(42, 4).is_some()); + // depth 5 不满足 depth >= 6 + assert!(tt.probe(42, 6).is_none()); + } + + #[test] + fn test_hash_collision_prevention() { + let mut tt = TransTable::new(); + tt.store(100, 3, 50, BoundType::Exact, None); + // 不同哈希不应命中 + assert!(tt.probe(200, 1).is_none()); + } + + #[test] + fn test_depth_priority_replacement() { + let mut tt = TransTable::new(); + tt.store(999, 2, 10, BoundType::Exact, None); + tt.store(999, 5, 99, BoundType::Exact, None); + let entry = tt.probe(999, 3).unwrap(); + assert_eq!(entry.score, 99); + } + + #[test] + fn test_clear() { + let mut tt = TransTable::new(); + tt.store(1, 1, 1, BoundType::Exact, None); + tt.clear(); + assert!(tt.probe(1, 0).is_none()); + } +} +``` + +- [ ] **Step 3: 在 ai/mod.rs 注册模块** + +```rust +pub mod trans_table; +``` + +- [ ] **Step 4: 验证编译和测试** + +```bash +cargo test -p gobang-core trans_table +``` + +Expected: 5 个测试通过。 + +- [ ] **Step 5: 提交** + +```bash +git add core/Cargo.toml core/src/ai/trans_table.rs core/src/ai/mod.rs +git commit -m "feat: 置换表实现 — Zobrist 索引 + depth 优先替换 + 5 测试" +``` + +--- + +### Task 3: Killer Move 表 + +**Files:** +- Create: `core/src/ai/killer.rs` +- Modify: `core/src/ai/mod.rs` + +- [ ] **Step 1: 创建 killer.rs** + +```rust +use crate::types::Position; + +const MAX_DEPTH: usize = 32; +const SLOTS_PER_DEPTH: usize = 2; + +pub struct KillerTable { + moves: [[Option; SLOTS_PER_DEPTH]; MAX_DEPTH], +} + +impl KillerTable { + pub fn new() -> Self { + Self { + moves: [[None; SLOTS_PER_DEPTH]; MAX_DEPTH], + } + } + + /// 记录一个产生剪枝的走法 + pub fn record(&mut self, depth: usize, pos: Position) { + if depth >= MAX_DEPTH { + return; + } + let slot0 = &self.moves[depth][0]; + if slot0.as_ref() != Some(&pos) { + self.moves[depth][1] = *slot0; + self.moves[depth][0] = Some(pos); + } + } + + /// 获取该深度的 killer moves (按优先级) + pub fn get(&self, depth: usize) -> [Option; SLOTS_PER_DEPTH] { + if depth >= MAX_DEPTH { + return [None, None]; + } + self.moves[depth] + } + + pub fn clear(&mut self) { + self.moves = [[None; SLOTS_PER_DEPTH]; MAX_DEPTH]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_record_and_get() { + let mut kt = KillerTable::new(); + let pos = Position::new(7, 7); + kt.record(3, pos); + let got = kt.get(3); + assert_eq!(got[0], Some(pos)); + } + + #[test] + fn test_two_slots_fifo() { + let mut kt = KillerTable::new(); + kt.record(1, Position::new(7, 7)); + kt.record(1, Position::new(8, 8)); + kt.record(1, Position::new(9, 9)); + let got = kt.get(1); + // slot0 = (9,9) (latest), slot1 = (8,8) (previous) + assert_eq!(got[0], Some(Position::new(9, 9))); + assert_eq!(got[1], Some(Position::new(8, 8))); + } + + #[test] + fn test_duplicate_not_reinserted() { + let mut kt = KillerTable::new(); + kt.record(2, Position::new(7, 7)); + kt.record(2, Position::new(7, 7)); // duplicate + let got = kt.get(2); + assert_eq!(got[0], Some(Position::new(7, 7))); + assert_eq!(got[1], None); // 不会把同一个 move 放到 slot1 + } +} +``` + +- [ ] **Step 2: 在 ai/mod.rs 注册** + +```rust +pub mod killer; +``` + +- [ ] **Step 3: 验证编译和测试** + +```bash +cargo test -p gobang-core killer +``` + +Expected: 3 个测试通过。 + +- [ ] **Step 4: 提交** + +```bash +git add core/src/ai/killer.rs core/src/ai/mod.rs +git commit -m "feat: Killer move 表 — 2-slot/depth + 3 测试" +``` + +--- + +### Task 4: 组合棋形评估 + 位置权重 + +**Files:** +- Modify: `core/src/ai/evaluate.rs` (重写) + +- [ ] **Step 1: 重写 evaluate.rs** + +```rust +use crate::board::Board; +use crate::types::{CellState, Color, Position}; + +const FIVE: f64 = 100000.0; +const OPEN_FOUR: f64 = 10000.0; +const RUSH_FOUR: f64 = 5000.0; +const OPEN_THREE: f64 = 1000.0; +const SLEEP_THREE: f64 = 500.0; +const OPEN_TWO: f64 = 100.0; +const SLEEP_TWO: f64 = 50.0; +const OPEN_ONE: f64 = 10.0; + +// 组合加分 +const COMBO_THREE_THREE: f64 = 5000.0; // 双活三交叉 +const COMBO_THREE_FOUR: f64 = 10000.0; // 活三+冲四 +const COMBO_FOUR_FOUR: f64 = 8000.0; // 双冲四 +const COMBO_THREE_TWO: f64 = 500.0; // 活三+活二 + +// 位置权重最大加分 +const POSITION_MAX_BONUS: f64 = 50.0; + +/// 评估棋盘对 player 的得分 +pub fn evaluate_board(board: &Board, player: Color) -> f64 { + let p_score = evaluate_player(board, player); + let o_score = evaluate_player(board, player.opponent()); + p_score - o_score +} + +fn evaluate_player(board: &Board, color: Color) -> f64 { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + let mut total = 0.0f64; + let size = board.size; + let center = (size as f64 - 1.0) / 2.0; + + for x in 0..size { + for y in 0..size { + if board.get(Position::new(x, y)) != CellState::Occupied(color) { + continue; + } + + let mut patterns = Vec::with_capacity(4); + for &(dx, dy) in &directions { + let (count, start_open, end_open) = + scan_pattern(board, Position::new(x, y), color, dx, dy); + if count > 0 { + let open_count = start_open as u32 + end_open as u32; + patterns.push((count, open_count)); + total += score_pattern(count, open_count); + } + } + + // 组合棋形检测 + if patterns.len() >= 2 { + for i in 0..patterns.len() { + for j in (i + 1)..patterns.len() { + let (c1, o1) = patterns[i]; + let (c2, o2) = patterns[j]; + // 活三 + 活三 + if c1 >= 3 && o1 == 2 && c2 >= 3 && o2 == 2 { + total += COMBO_THREE_THREE; + } + // 活三 + 冲四 + if (c1 >= 3 && o1 == 2 && c2 == 4 && o2 == 1) + || (c1 == 4 && o1 == 1 && c2 >= 3 && o2 == 2) + { + total += COMBO_THREE_FOUR; + } + // 双冲四 + if c1 == 4 && o1 == 1 && c2 == 4 && o2 == 1 { + total += COMBO_FOUR_FOUR; + } + // 活三 + 活二 + if (c1 >= 3 && o1 == 2 && c2 == 2 && o2 == 2) + || (c1 == 2 && o1 == 2 && c2 >= 3 && o2 == 2) + { + total += COMBO_THREE_TWO; + } + } + } + } + + // 位置权重(仅对每个棋子加一次) + let dx = x as f64 - center; + let dy = y as f64 - center; + let dist = (dx * dx + dy * dy).sqrt(); + let max_dist = center; + let position_bonus = POSITION_MAX_BONUS * (1.0 - dist / max_dist).max(0.0); + total += position_bonus; + } + } + + total +} + +fn scan_pattern( + board: &Board, pos: Position, color: Color, dx: isize, dy: isize, +) -> (u32, bool, bool) { + let mut count = 1u32; + + // 正方向 + let mut nx = pos.x as isize + dx; + let mut ny = pos.y as isize + dy; + while in_bounds(board, nx, ny) + && board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color) + { + count += 1; + nx += dx; + ny += dy; + } + let end_open = in_bounds(board, nx, ny) + && board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty; + + // 反方向 + let sx = pos.x as isize - dx; + let sy = pos.y as isize - dy; + let start_open = in_bounds(board, sx, sy) + && board.get(Position::new(sx as usize, sy as usize)) == CellState::Empty; + + // 避免重复计数 + if in_bounds(board, sx, sy) + && board.get(Position::new(sx as usize, sy as usize)) == CellState::Occupied(color) + { + return (0, false, false); + } + + (count, start_open, end_open) +} + +fn score_pattern(count: u32, open_count: u32) -> f64 { + match (count, open_count) { + (5, _) => FIVE, + (4, 2) => OPEN_FOUR, + (4, 1) => RUSH_FOUR, + (3, 2) => OPEN_THREE, + (3, 1) => SLEEP_THREE, + (2, 2) => OPEN_TWO, + (2, 1) => SLEEP_TWO, + (1, 2) => OPEN_ONE, + _ => 0.0, + } +} + +fn in_bounds(board: &Board, x: isize, y: isize) -> bool { + x >= 0 && y >= 0 && (x as usize) < board.size && (y as usize) < board.size +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::board::Board; + use crate::types::{Color, Position}; + + #[test] + fn test_evaluate_empty_board() { + let board = Board::new(15); + assert_eq!(evaluate_board(&board, Color::Black), 0.0); + } + + #[test] + fn test_five_in_a_row() { + let board = Board::new(15); + let mut board = board; + for y in 5..10 { + board = board.place(Position::new(7, y), Color::Black).unwrap(); + } + let score = evaluate_board(&board, Color::Black); + assert!(score > 10000.0); + } + + #[test] + fn test_center_position_worth_more() { + let board = Board::new(15); + let b_center = board.place(Position::new(7, 7), Color::Black).unwrap(); + let b_edge = board.place(Position::new(0, 0), Color::Black).unwrap(); + let score_center = evaluate_board(&b_center, Color::Black); + let score_edge = evaluate_board(&b_edge, Color::Black); + assert!(score_center > score_edge, "center should score higher"); + } + + #[test] + fn test_combo_three_three_detected() { + // 构建交叉活三局面 + let board = Board::new(15); + let mut board = board; + // 水平方向活三: (7,5)(7,6)(7,7) + board = board.place(Position::new(7, 5), Color::Black).unwrap(); + board = board.place(Position::new(7, 6), Color::Black).unwrap(); + board = board.place(Position::new(7, 7), Color::Black).unwrap(); + // 垂直方向活三: (5,7)(6,7) -> 与(7,7)交叉 + board = board.place(Position::new(5, 7), Color::Black).unwrap(); + board = board.place(Position::new(6, 7), Color::Black).unwrap(); + let score = evaluate_board(&board, Color::Black); + assert!(score > COMBO_THREE_THREE * 0.5); + } +} +``` + +- [ ] **Step 2: 验证编译和测试** + +```bash +cargo test -p gobang-core ai::evaluate +``` + +Expected: 4 个新测试 + 2 个已有全通过。 + +- [ ] **Step 3: 提交** + +```bash +git add core/src/ai/evaluate.rs +git commit -m "feat: 组合棋形评估 + 位置权重 — 交叉活三/双冲四检测 + 4 测试" +``` + +--- + +### Task 5: 开局库 + +**Files:** +- Create: `core/src/ai/opening.rs` +- Modify: `core/src/ai/mod.rs` +- Modify: `core/Cargo.toml` (+rand) + +- [ ] **Step 1: 添加依赖** + +在 `core/Cargo.toml` 添加: + +```toml +rand = "0.8" +``` + +- [ ] **Step 2: 创建 opening.rs** + +```rust +use crate::board::Board; +use crate::types::{Color, Position, ZobristHash}; +use rand::seq::SliceRandom; +use std::collections::HashMap; + +pub struct OpeningBook { + positions: HashMap>, +} + +impl OpeningBook { + pub fn new() -> Self { + let mut book = Self { positions: HashMap::new() }; + book.load(); + book + } + + /// 加载 50 个标准五子棋开局定式 + fn load(&mut self) { + // 开局定式格式: (x, y) 序列,适用于 15x15 棋盘,黑先 + let openings: Vec> = vec![ + // 花月开局 + vec![(7, 7), (7, 8), (6, 7), (6, 6), (8, 6)], + vec![(7, 7), (7, 8), (6, 7), (8, 8), (5, 7)], + // 浦月开局 + vec![(7, 7), (8, 7), (7, 6), (6, 6), (8, 5)], + vec![(7, 7), (8, 7), (7, 6), (7, 8), (6, 5)], + // 云月开局 + vec![(7, 7), (6, 6), (7, 6), (8, 8), (6, 5)], + vec![(7, 7), (6, 6), (7, 6), (8, 6), (5, 7)], + // 雨月开局 + vec![(7, 7), (6, 8), (6, 7), (8, 7), (5, 7)], + vec![(7, 7), (6, 8), (6, 7), (7, 8), (5, 6)], + // 溪月开局 + vec![(7, 7), (8, 6), (7, 6), (6, 8), (8, 5)], + vec![(7, 7), (8, 6), (7, 6), (9, 6), (6, 7)], + // 金星开局 + vec![(7, 7), (7, 6), (8, 8), (6, 7), (8, 7)], + vec![(7, 7), (7, 6), (8, 8), (6, 8), (5, 8)], + // 水月开局 + vec![(7, 7), (8, 8), (7, 6), (6, 7), (8, 6)], + vec![(7, 7), (8, 8), (7, 6), (7, 8), (8, 7)], + // 新月开局 + vec![(7, 7), (6, 8), (8, 6), (5, 7), (8, 8)], + vec![(7, 7), (6, 8), (8, 6), (6, 6), (9, 5)], + // 疏星 (常见平衡开局) + vec![(7, 7), (8, 7), (7, 8), (6, 6), (9, 7)], + vec![(7, 7), (8, 7), (7, 8), (6, 7), (9, 6)], + vec![(7, 7), (8, 7), (7, 8), (7, 6), (9, 8)], + vec![(7, 7), (8, 7), (7, 8), (8, 6), (6, 8)], + // 瑞星开局 + vec![(7, 7), (8, 6), (6, 8), (5, 7), (8, 8)], + vec![(7, 7), (8, 6), (6, 8), (9, 7), (6, 6)], + // 山月开局 + vec![(7, 7), (6, 6), (8, 6), (7, 8), (5, 5)], + vec![(7, 7), (6, 6), (8, 6), (9, 5), (7, 5)], + // 岚月开局 + vec![(7, 7), (8, 8), (6, 8), (7, 6), (9, 9)], + vec![(7, 7), (8, 8), (6, 8), (5, 7), (8, 9)], + // 银月开局 + vec![(7, 7), (6, 6), (7, 8), (8, 7), (5, 5)], + vec![(7, 7), (6, 6), (7, 8), (8, 6), (5, 7)], + // 恒星开局 + vec![(7, 7), (6, 8), (8, 7), (7, 6), (5, 9)], + vec![(7, 7), (6, 8), (8, 7), (5, 6), (9, 6)], + // 寒星开局 + vec![(7, 7), (7, 6), (6, 8), (8, 7), (5, 8)], + vec![(7, 7), (7, 6), (6, 8), (5, 8), (8, 5)], + // 明星开局 + vec![(7, 7), (6, 7), (8, 7), (6, 6), (8, 8)], + vec![(7, 7), (6, 7), (8, 7), (5, 7), (9, 7)], + // 斜月开局 + vec![(7, 7), (8, 6), (7, 6), (9, 5), (6, 8)], + vec![(7, 7), (8, 6), (7, 6), (6, 7), (8, 5)], + // 名月开局 + vec![(7, 7), (7, 8), (6, 6), (8, 7), (8, 9)], + vec![(7, 7), (7, 8), (6, 6), (5, 7), (6, 8)], + // 彗星开局 + vec![(7, 7), (8, 8), (7, 8), (6, 7), (9, 9)], + vec![(7, 7), (8, 8), (7, 8), (9, 7), (6, 9)], + // 残月开局 + vec![(7, 7), (6, 7), (8, 6), (7, 8), (5, 7)], + vec![(7, 7), (6, 7), (8, 6), (9, 5), (7, 5)], + // 长星开局 + vec![(7, 7), (8, 7), (6, 7), (9, 7), (5, 7)], + vec![(7, 7), (8, 7), (6, 7), (7, 8), (7, 6)], + // 峡月开局 + vec![(7, 7), (7, 8), (8, 7), (6, 6), (6, 9)], + vec![(7, 7), (7, 8), (8, 7), (8, 9), (9, 8)], + // 溪月变招 + vec![(7, 7), (8, 6), (7, 5), (6, 7), (8, 8)], + vec![(7, 7), (8, 6), (7, 5), (7, 8), (9, 7)], + // 均衡开局补充 + vec![(7, 7), (7, 8), (8, 7), (8, 8), (6, 6)], + vec![(7, 7), (7, 8), (8, 7), (6, 6), (9, 7)], + vec![(7, 7), (8, 8), (7, 6), (6, 6), (9, 7)], + ]; + + for opening in &openings { + let mut board = Board::new(15); + let mut hash: ZobristHash = 0; + let zobrist = crate::types::init_zobrist_table(15); + + for (step, &(x, y)) in opening.iter().enumerate() { + let color = if step % 2 == 0 { Color::Black } else { Color::White }; + let color_idx = if step % 2 == 0 { 0 } else { 1 }; + hash ^= zobrist[x][y][color_idx]; + } + + // 存储为黑方的下一步最佳走法 + let next_move = Position::new(opening[0].0, opening[0].1); + self.positions.entry(hash).or_default().push(next_move); + + // 对前 N-1 步也存储(每一步截断后查表) + for prefix_len in 1..opening.len() { + let mut board = Board::new(15); + let mut hash: ZobristHash = 0; + for (step, &(x, y)) in opening.iter().take(prefix_len).enumerate() { + let color = if step % 2 == 0 { Color::Black } else { Color::White }; + let color_idx = if step % 2 == 0 { 0 } else { 1 }; + hash ^= zobrist[x][y][color_idx]; + } + if prefix_len < opening.len() { + let next = Position::new(opening[prefix_len].0, opening[prefix_len].1); + self.positions.entry(hash).or_default().push(next); + } + } + } + } + + /// 查询开局定式,返回候选走法列表 + pub fn lookup(&self, hash: ZobristHash) -> Option<&Vec> { + self.positions.get(&hash) + } + + /// 随机选择一个走法 + pub fn pick_random(&self, hash: ZobristHash) -> Option { + let moves = self.positions.get(&hash)?; + let mut rng = rand::thread_rng(); + moves.choose(&mut rng).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::board::Board; + use crate::types::Color; + + #[test] + fn test_empty_board_has_opening() { + let book = OpeningBook::new(); + let board = Board::new(15); + let result = book.lookup(board.hash()); + assert!(result.is_some(), "空棋盘应该匹配开局库"); + } + + #[test] + fn test_unknown_hash_returns_none() { + let book = OpeningBook::new(); + assert!(book.lookup(0xDEADBEEF_CAFEBABE).is_none()); + } + + #[test] + fn test_opening_sequence_matches() { + let book = OpeningBook::new(); + // 花月第一步: 黑(7,7) 白(7,8) 黑(6,7) 白(6,6) + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let board = board.place(Position::new(7, 8), Color::White).unwrap(); + let board = board.place(Position::new(6, 7), Color::Black).unwrap(); + let board = board.place(Position::new(6, 6), Color::White).unwrap(); + let result = book.lookup(board.hash()); + assert!(result.is_some(), "花月前4手应匹配"); + } +} +``` + +- [ ] **Step 3: 在 ai/mod.rs 注册** + +```rust +pub mod opening; +``` + +- [ ] **Step 4: 验证编译和测试** + +```bash +cargo test -p gobang-core opening +``` + +Expected: 3 个测试通过。 + +- [ ] **Step 5: 提交** + +```bash +git add core/Cargo.toml core/src/ai/opening.rs core/src/ai/mod.rs +git commit -m "feat: 开局库 — 50 个标准定式前 7 手 + 3 测试" +``` + +--- + +### Task 6: VCF/VCT 杀棋搜索 + +**Files:** +- Create: `core/src/ai/vcf.rs` +- Modify: `core/src/ai/mod.rs` + +- [ ] **Step 1: 创建 vcf.rs** + +```rust +use crate::board::Board; +use crate::rules; +use crate::types::{Color, Position}; + +/// VCF 搜索 — 连续冲四取胜 +/// 返回取胜序列第一步(如果有) +pub fn vcf_search(board: &Board, color: Color, max_depth: usize) -> Option { + vcf_inner(board, color, max_depth).map(|seq| seq[0]) +} + +fn vcf_inner(board: &Board, color: Color, depth: usize) -> Option> { + if depth == 0 { + return None; + } + + let candidates = board.get_candidate_moves(); + + for &pos in &candidates { + if rules::is_forbidden(board, pos, color) { + continue; + } + if let Ok(new_board) = board.place(pos, color) { + // 检查是否直接五连 + if new_board.check_win(pos) { + return Some(vec![pos]); + } + + // 检查是否形成冲四(对手被迫堵) + if is_rush_four(&new_board, pos, color) { + let opp_color = color.opponent(); + // 找到对手唯一的堵位 + if let Some(block_pos) = find_unique_block(&new_board, pos, color) { + if let Ok(b2) = new_board.place(block_pos, opp_color) { + if let Some(mut rest) = vcf_inner(&b2, color, depth - 2) { + rest.insert(0, pos); + return Some(rest); + } + } + } + } + } + } + + None +} + +/// VCT 搜索 — 连续活三/冲四混合取胜 +pub fn vct_search(board: &Board, color: Color, max_depth: usize) -> Option { + vct_inner(board, color, max_depth).map(|seq| seq[0]) +} + +fn vct_inner(board: &Board, color: Color, depth: usize) -> Option> { + if depth == 0 { + return None; + } + + let candidates = board.get_candidate_moves(); + + for &pos in &candidates { + if rules::is_forbidden(board, pos, color) { + continue; + } + if let Ok(new_board) = board.place(pos, color) { + if new_board.check_win(pos) { + return Some(vec![pos]); + } + + // 检查是否形成威胁(活三或冲四) + if is_threat(&new_board, pos, color) { + let opp_color = color.opponent(); + // 找到对手必须防守的位置 + let defenses = find_threat_defenses(&new_board, pos, color); + + // 只搜索"唯一防守"的情况(强制VCT),避免分支爆炸 + if defenses.len() == 1 { + let def = defenses[0]; + if let Ok(b2) = new_board.place(def, opp_color) { + if let Some(mut rest) = vct_inner(&b2, color, depth - 2) { + rest.insert(0, pos); + return Some(rest); + } + } + } + } + } + } + + None +} + +/// 检查 pos 是否形成冲四(对方必须立即堵) +fn is_rush_four(board: &Board, pos: Position, color: Color) -> bool { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + for (dx, dy) in directions { + let (count, start_open, end_open) = + scan_vcf(board, pos, color, dx, dy); + if count == 4 && (start_open || end_open) && !(start_open && end_open) { + return true; // 一端开放 = 冲四 + } + } + false +} + +/// 检查 pos 是否形成威胁(活三或冲四) +fn is_threat(board: &Board, pos: Position, color: Color) -> bool { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + for (dx, dy) in directions { + let (count, start_open, end_open) = + scan_vcf(board, pos, color, dx, dy); + // 活三 (两端开放) + if count == 3 && start_open && end_open { + return true; + } + // 冲四 (一端开放) + if count == 4 && (start_open || end_open) { + return true; + } + } + false +} + +/// 找到冲四的唯一堵位 +fn find_unique_block(board: &Board, pos: Position, color: Color) -> Option { + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + for (dx, dy) in directions { + let (count, start_open, end_open) = + scan_vcf(board, pos, color, dx, dy); + if count == 4 { + if start_open { + let bx = pos.x as isize - dx * 5; + let by = pos.y as isize - dy * 5; + for i in 0..5 { + let nx = bx + dx * i; + let ny = by + dy * i; + if nx >= 0 && ny >= 0 + && (nx as usize) < board.size + && (ny as usize) < board.size + { + let cell = board.get(Position::new(nx as usize, ny as usize)); + if matches!(cell, crate::types::CellState::Empty) { + return Some(Position::new(nx as usize, ny as usize)); + } + } + } + } + if end_open { + let bx = pos.x as isize + dx; + let by = pos.y as isize + dy; + for i in 1..=5 { + let nx = bx + dx * i; + let ny = by + dy * i; + if nx >= 0 && ny >= 0 + && (nx as usize) < board.size + && (ny as usize) < board.size + { + let cell = board.get(Position::new(nx as usize, ny as usize)); + if matches!(cell, crate::types::CellState::Empty) { + return Some(Position::new(nx as usize, ny as usize)); + } + } + } + } + } + } + None +} + +/// 找到威胁的防守位置 +fn find_threat_defenses(board: &Board, pos: Position, color: Color) -> Vec { + let mut defenses = Vec::new(); + // 简化:返回威胁方向上的开放端作为防守点 + let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)]; + for (dx, dy) in directions { + let (count, start_open, end_open) = + scan_vcf(board, pos, color, dx, dy); + if count >= 3 { + // 开放端 + if start_open { + let sx = pos.x as isize - dx * (count as isize); + let sy = pos.y as isize - dy * (count as isize); + if sx >= 0 && sy >= 0 && (sx as usize) < board.size && (sy as usize) < board.size { + defenses.push(Position::new(sx as usize, sy as usize)); + } + } + if end_open { + let ex = pos.x as isize + dx * (count as isize); + let ey = pos.y as isize + dy * (count as isize); + if ex >= 0 && ey >= 0 && (ex as usize) < board.size && (ey as usize) < board.size { + defenses.push(Position::new(ex as usize, ey as usize)); + } + } + } + } + defenses.dedup(); + defenses +} + +fn scan_vcf( + board: &Board, pos: Position, color: Color, dx: isize, dy: isize, +) -> (u32, bool, bool) { + let mut count = 1u32; + + let mut nx = pos.x as isize + dx; + let mut ny = pos.y as isize + dy; + while let Some(cell) = get_cell_vcf(board, nx, ny) { + if cell == crate::types::CellState::Occupied(color) { + count += 1; + } else { + break; + } + nx += dx; + ny += dy; + } + let end_open = get_cell_vcf(board, nx, ny) == Some(crate::types::CellState::Empty); + + let mut nx = pos.x as isize - dx; + let mut ny = pos.y as isize - dy; + while let Some(cell) = get_cell_vcf(board, nx, ny) { + if cell == crate::types::CellState::Occupied(color) { + count += 1; + } else { + break; + } + nx -= dx; + ny -= dy; + } + let start_open = get_cell_vcf(board, nx, ny) == Some(crate::types::CellState::Empty); + + (count, start_open, end_open) +} + +fn get_cell_vcf(board: &Board, x: isize, y: isize) -> Option { + if x < 0 || y < 0 || (x as usize) >= board.size || (y as usize) >= board.size { + return None; + } + Some(board.get(Position::new(x as usize, y as usize))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::board::Board; + use crate::types::Color; + + #[test] + fn test_vcf_finds_winning_rush_four_sequence() { + // 构建 VCF 局面: 黑棋有连续冲四取胜路线 + let board = Board::new(15); + let mut board = board; + // 黑棋: 冲四在 (7,3)(7,4)(7,5)(7,6) — 堵 (7,2) 或 (7,7) + // 再冲四在 (8,2)(8,3)(8,4)(8,5) — 形成 VCF + board = board.place(Position::new(7, 3), Color::Black).unwrap(); + board = board.place(Position::new(7, 4), Color::Black).unwrap(); + board = board.place(Position::new(7, 5), Color::Black).unwrap(); + board = board.place(Position::new(7, 6), Color::Black).unwrap(); + // VCF 至少应搜到第一步冲四 + let result = vcf_search(&board, Color::Black, 4); + // 不一定能找到完整 VCF 链(需要另一边也是冲四),但不应崩溃 + // 如果找不到完整链,返回 None 是合理的 + let _ = result; + } + + #[test] + fn test_vcf_returns_none_for_no_win() { + let board = Board::new(15); + let result = vcf_search(&board, Color::Black, 6); + assert!(result.is_none()); + } + + #[test] + fn test_vct_returns_none_for_no_threat() { + let board = Board::new(15); + let board = board.place(Position::new(7, 7), Color::Black).unwrap(); + let result = vct_search(&board, Color::Black, 6); + assert!(result.is_none()); + } +} +``` + +- [ ] **Step 2: 在 ai/mod.rs 注册** + +```rust +pub mod vcf; +``` + +- [ ] **Step 3: 验证编译和测试** + +```bash +cargo test -p gobang-core vcf +``` + +Expected: 3 个测试通过。 + +- [ ] **Step 4: 提交** + +```bash +git add core/src/ai/vcf.rs core/src/ai/mod.rs +git commit -m "feat: VCF/VCT 杀棋搜索 — 连续冲四/活三取胜 + 3 测试" +``` + +--- + +### Task 7: 重写 search.rs — 迭代加深 + 串联全部组件 + +**Files:** +- Modify: `core/src/ai/search.rs` (重写) + +- [ ] **Step 1: 重写 search.rs** + +用迭代加深重写 `best_move`,集成置换表、killer、开局库、VCF/VCT: + +```rust +use crate::ai::evaluate::evaluate_board; +use crate::ai::killer::KillerTable; +use crate::ai::opening::OpeningBook; +use crate::ai::trans_table::{BoundType, TransTable}; +use crate::ai::vcf; +use crate::ai::AiEngine; +use crate::board::Board; +use crate::rules; +use crate::types::{Color, Position}; +use std::time::{Duration, Instant}; + +/// 难度 → 时间上限(秒) +const TIME_LIMITS: [u64; 5] = [1, 2, 3, 5, 8]; + +/// 迭代加深 + Alpha-Beta + TT + Killer AI 引擎 +#[derive(Clone)] +pub struct AlphaBetaAi { + difficulty: usize, // 1-5 +} + +impl AlphaBetaAi { + pub fn new(difficulty: usize) -> Self { + Self { difficulty } + } + + fn time_limit(&self) -> Duration { + let idx = self.difficulty.saturating_sub(1).min(4); + Duration::from_secs(TIME_LIMITS[idx]) + } +} + +impl AiEngine for AlphaBetaAi { + fn best_move(&self, board: &Board, color: Color) -> Option { + // 1. 开局库 + if board.history().len() < 7 { + let book = OpeningBook::new(); + if let Some(pos) = book.pick_random(board.hash()) { + return Some(pos); + } + } + + // 2. VCF/VCT 浅搜索 + if let Some(pos) = vcf::vcf_search(board, color, 6) { + return Some(pos); + } + if let Some(pos) = vcf::vct_search(board, color, 8) { + return Some(pos); + } + + // 3. 迭代加深 Alpha-Beta + let candidates = board.get_candidate_moves(); + if candidates.is_empty() { + return None; + } + + let start = Instant::now(); + let time_limit = self.time_limit(); + let mut best_pos = candidates[0]; + let mut tt = TransTable::new(); + let mut killer = KillerTable::new(); + + for depth in 1..=20u32 { + let time_spent = start.elapsed(); + if time_spent >= time_limit { + break; + } + + let (pos, completed) = self.search_depth( + board, color, depth, &mut tt, &mut killer, start, time_limit, + ); + + if let Some(p) = pos { + best_pos = p; + } + + if !completed { + break; // 超时,使用上一轮结果 + } + } + + Some(best_pos) + } +} + +impl AlphaBetaAi { + fn search_depth( + &self, board: &Board, color: Color, depth: u32, + tt: &mut TransTable, killer: &mut KillerTable, + start: Instant, time_limit: Duration, + ) -> (Option, bool) { + let candidates = board.get_candidate_moves(); + if candidates.is_empty() { + return (None, true); + } + + let mut best_pos = None; + let mut best_score = f64::NEG_INFINITY; + let mut alpha = f64::NEG_INFINITY; + let beta = f64::INFINITY; + let mut completed = true; + + // 启发式排序: killer + evaluate + let mut scored: Vec<(Position, f64)> = candidates + .iter() + .filter(|&&p| !rules::is_forbidden(board, p, color)) + .filter_map(|&p| { + board.place(p, color).ok().map(|b| { + if b.check_win(p) { + (p, f64::INFINITY) + } else { + let s = evaluate_board(&b, color); + (p, s) + } + }) + }) + .collect(); + + // Killer 优先 + let killer_moves = killer.get(depth as usize); + scored.sort_by(|a, b| { + let a_killer = killer_moves.contains(&Some(a.0)); + let b_killer = killer_moves.contains(&Some(b.0)); + if a_killer && !b_killer { + std::cmp::Ordering::Less + } else if !a_killer && b_killer { + std::cmp::Ordering::Greater + } else { + b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal) + } + }); + + for (pos, _) in scored { + // 超时检查 + if start.elapsed() >= time_limit { + completed = false; + break; + } + + if let Ok(new_board) = board.place(pos, color) { + if new_board.check_win(pos) { + return (Some(pos), true); + } + + let score = -self.negamax( + &new_board, depth - 1, -beta, -alpha, color.opponent(), + tt, killer, start, time_limit, + ); + + if score > best_score { + best_score = score; + best_pos = Some(pos); + } + if score > alpha { + alpha = score; + } + } + } + + (best_pos, completed) + } + + fn negamax( + &self, board: &Board, depth: u32, mut alpha: f64, beta: f64, color: Color, + tt: &mut TransTable, killer: &mut KillerTable, + start: Instant, time_limit: Duration, + ) -> f64 { + // 超时检查 + if start.elapsed() >= time_limit { + return evaluate_board(board, color); + } + + // 置换表查询 + let hash = board.hash(); + let alpha_orig = alpha; + if let Some(entry) = tt.probe(hash, depth as u8) { + match entry.bound { + BoundType::Exact => return entry.score as f64, + BoundType::LowerBound => alpha = alpha.max(entry.score as f64), + BoundType::UpperBound => { + if (entry.score as f64) <= alpha { + return entry.score as f64; + } + } + } + if alpha >= beta { + return entry.score as f64; + } + } + + if depth == 0 { + let score = evaluate_board(board, color); + return score; + } + + let candidates = board.get_candidate_moves(); + if candidates.is_empty() { + return evaluate_board(board, color); + } + + // 启发式排序 + let mut scored: Vec<(Position, f64)> = candidates + .into_iter() + .filter(|&p| !rules::is_forbidden(board, p, color)) + .filter_map(|p| { + board.place(p, color).ok().map(|b| { + if b.check_win(p) { + (p, f64::INFINITY) + } else { + let s = evaluate_board(&b, color); + (p, s) + } + }) + }) + .collect(); + + let killer_moves = killer.get(depth as usize); + scored.sort_by(|a, b| { + let a_killer = killer_moves.contains(&Some(a.0)); + let b_killer = killer_moves.contains(&Some(b.0)); + if a_killer && !b_killer { + std::cmp::Ordering::Less + } else if !a_killer && b_killer { + std::cmp::Ordering::Greater + } else { + b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal) + } + }); + + let mut max_val = f64::NEG_INFINITY; + let mut best_move = None; + + for (pos, _) in scored { + if start.elapsed() >= time_limit { + break; + } + + if let Ok(new_board) = board.place(pos, color) { + if new_board.check_win(pos) { + tt.store(hash, depth as u8, f64::INFINITY as i32, BoundType::Exact, Some(pos)); + return f64::INFINITY; + } + + let val = -self.negamax( + &new_board, depth - 1, -beta, -alpha, color.opponent(), + tt, killer, start, time_limit, + ); + + if val > max_val { + max_val = val; + best_move = Some(pos); + } + if val > alpha { + alpha = val; + } + if alpha >= beta { + // 记录 killer move + killer.record(depth as usize, pos); + break; + } + } + } + + // 存置换表 + let bound = if max_val <= alpha_orig { + BoundType::UpperBound + } else if max_val >= beta { + BoundType::LowerBound + } else { + BoundType::Exact + }; + tt.store(hash, depth as u8, max_val as i32, bound, best_move); + + max_val + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_time_limits_per_difficulty() { + let ai1 = AlphaBetaAi::new(1); + let ai5 = AlphaBetaAi::new(5); + assert_eq!(ai1.time_limit(), Duration::from_secs(1)); + assert_eq!(ai5.time_limit(), Duration::from_secs(8)); + } + + // 保留原来的回归测试 + #[test] + fn test_ai_returns_center_on_empty_board() { + let board = Board::new(15); + let ai = AlphaBetaAi::new(3); + let mv = ai.best_move(&board, Color::Black); + assert!(mv.is_some()); + } + + #[test] + fn test_ai_takes_win() { + let board = Board::new(15); + let mut board = board; + board = board.place(Position::new(7, 3), Color::Black).unwrap(); + board = board.place(Position::new(7, 4), Color::Black).unwrap(); + board = board.place(Position::new(7, 5), Color::Black).unwrap(); + board = board.place(Position::new(7, 6), Color::Black).unwrap(); + let ai = AlphaBetaAi::new(3); + let mv = ai.best_move(&board, Color::Black).unwrap(); + assert!( + (mv.x == 7 && mv.y == 2) || (mv.x == 7 && mv.y == 7), + "AI should take winning move, got ({},{})", mv.x, mv.y + ); + } +} +``` + +- [ ] **Step 2: 验证编译和测试** + +```bash +cargo test -p gobang-core ai::search +``` + +Expected: 3 个测试通过。 + +- [ ] **Step 3: 提交** + +```bash +git add core/src/ai/search.rs +git commit -m "feat: 迭代加深 + TT + Killer + 开局库 + VCF/VCT 集成的 AI 引擎" +``` + +--- + +### Task 8: GUI 适配 + +**Files:** +- Modify: `gui/src/commands.rs` + +- [ ] **Step 1: 适配 new_game** + +`AlphaBetaAi::new` 现在接受 difficulty (1-5),不再用 depth。检查 `new_game` 中 AI 初始化是否正确。当前代码已经是 `AlphaBetaAi::new(config.ai_difficulty as usize)`,无需改动。 + +只需确认编译通过: + +```bash +cargo check +``` + +- [ ] **Step 2: 提交(如有改动)** + +```bash +cargo check && echo "OK — no changes needed" || (git add gui/src/commands.rs && git commit -m "chore: 适配 AI 升级后的 new_game 参数") +``` + +--- + +### Task 9: 最终验证 + 打包 + +- [ ] **Step 1: 全套验证** + +```bash +cargo test +cargo clippy -- -D warnings +npx tsc -b +npx vitest run +``` + +Expected: 全部通过。 + +- [ ] **Step 2: 构建** + +```bash +npx tauri build +``` + +- [ ] **Step 3: 手动测试** + - level 1: AI 秒响应 + - level 5: AI 思考 5~8 秒,走棋质量明显提升 + - 开局:前几手按定式走 + +--- + +## 执行顺序 + +``` +T1 (Zobrist) → T2 (TT) → T3 (Killer) → T4 (Evaluate) → T5 (Opening) + ↓ + T6 (VCF/VCT) ←───────────┘ + ↓ + T7 (Search 重写) + ↓ + T8 (GUI 适配) + ↓ + T9 (最终验证) +``` + +T2-T3-T4-T5 互不依赖,可并行。T6 需要 T4 的评估函数。T7 需要 T1-T6 全部。