Skip to main content

verify_perft/
verify_perft.rs

1//! Reads positions and expected node counts from `perft_values.md` and runs a
2//! perft at each listed depth, reporting which cases pass and which fail.
3//!
4//! Usage:
5//! ```sh
6//! cargo run --example verify_perft [max-depth] [path]
7//! ```
8//!
9//! Arguments:
10//! - `max-depth` — maximum depth to test (default: 6, omit or set to 0 to run all)
11//! - `path`      — path to the perft values markdown file (default: `perft_values.md`)
12//!
13//! The process exits with code 0 if every test passes, or 1 if any fail.
14
15use atomic_movegen::board::Board;
16use atomic_movegen::perft;
17use std::env;
18use std::fs;
19use std::process;
20use std::time::Instant;
21
22/// A single test case parsed from the markdown table.
23struct TestCase {
24    number: usize,
25    fen: String,
26    /// Pairs of (depth, expected_node_count).
27    depths: Vec<(u32, u64)>,
28}
29
30/// Parse the markdown table in `perft_values.md` into a list of test cases.
31///
32/// The expected format (as of this writing):
33///
34/// ```markdown
35/// | #   | Depth 1 | Depth 2 | ... | Depth 6    | FEN                                        |
36/// | --- | ------- | ------- | ... | ---------- | ------------------------------------------ |
37/// | 1   | 20      | 400     | ... | 118926425  | `rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/...`  |
38/// ```
39fn parse_perft_values(path: &str) -> Vec<TestCase> {
40    let content = fs::read_to_string(path).unwrap_or_else(|e| {
41        eprintln!("Error: could not read `{}`: {}", path, e);
42        process::exit(1);
43    });
44
45    let mut cases: Vec<TestCase> = Vec::new();
46
47    for line in content.lines() {
48        let trimmed = line.trim();
49
50        // Skip empty lines, headers, and separator rows.
51        if trimmed.is_empty()
52            || trimmed.starts_with("| #")
53            || trimmed.starts_with("| ---")
54            || !trimmed.starts_with('|')
55        {
56            continue;
57        }
58
59        // Split on pipe.  The leading pipe produces an empty first element.
60        let parts: Vec<&str> = trimmed.split('|').collect();
61        if parts.len() < 9 {
62            continue;
63        }
64
65        // Column 1 (index 1 after the leading empty) = row number.
66        let number: usize = match parts[1].trim().parse() {
67            Ok(n) => n,
68            Err(_) => continue,
69        };
70
71        // Columns 2-7 (indices 2..8) = depth values.
72        let depth_counts: Vec<u64> = parts[2..8]
73            .iter()
74            .map(|s| s.trim().replace([',', '_'], "").parse().unwrap_or(0))
75            .collect();
76
77        // Column 8 (index 8) = FEN, possibly wrapped in backticks.
78        let raw_fen = parts[8].trim();
79        let fen = if let Some(start) = raw_fen.find('`') {
80            if let Some(end) = raw_fen.rfind('`') {
81                raw_fen[start + 1..end].trim()
82            } else {
83                &raw_fen[start + 1..]
84            }
85        } else {
86            raw_fen
87        };
88        let fen = fen.trim().to_string();
89
90        let depths: Vec<(u32, u64)> = depth_counts
91            .into_iter()
92            .enumerate()
93            .map(|(i, count)| (i as u32 + 1, count))
94            .collect();
95
96        cases.push(TestCase {
97            number,
98            fen,
99            depths,
100        });
101    }
102
103    cases
104}
105
106fn main() {
107    atomic_movegen::attacks::init();
108    let args: Vec<String> = env::args().collect();
109
110    // Parse optional max depth (first positional arg).
111    let max_depth: u32 = if args.len() > 1 {
112        args[1].parse().unwrap_or(6)
113    } else {
114        6
115    };
116
117    // Parse optional file path (second positional arg, or default).
118    let path = if args.len() > 2 {
119        &args[2]
120    } else {
121        "perft_values.md"
122    };
123
124    // Limit to 0 means "no limit".
125    let max_depth = if max_depth == 0 { u32::MAX } else { max_depth };
126
127    let cases = parse_perft_values(path);
128
129    eprintln!(
130        "=== Perft verification ===\n  Source:     {path}\n  Max depth:  {}\n  Test cases: {}\n",
131        if max_depth == u32::MAX {
132            "unlimited".to_string()
133        } else {
134            max_depth.to_string()
135        },
136        cases.len()
137    );
138
139    let mut passed = 0u64;
140    let mut failed = 0u64;
141    let mut detail_lines: Vec<String> = Vec::new();
142
143    // Track timing for each test case.
144    struct CaseTiming {
145        number: usize,
146        elapsed: std::time::Duration,
147    }
148    let mut timings: Vec<CaseTiming> = Vec::with_capacity(cases.len());
149    let total_timer = Instant::now();
150
151    for case in &cases {
152        let mut board = match Board::from_fen(&case.fen) {
153            Ok(b) => b,
154            Err(e) => {
155                detail_lines.push(format!(
156                    "  Test #{}: INVALID FEN — {} ({})",
157                    case.number, case.fen, e
158                ));
159                failed += 1;
160                continue;
161            }
162        };
163
164        // Determine which depths to actually run.
165        let depths_to_test: Vec<(u32, u64)> = case
166            .depths
167            .iter()
168            .copied()
169            .filter(|(d, _)| *d <= max_depth)
170            .collect();
171
172        if depths_to_test.is_empty() {
173            continue;
174        }
175
176        let mut ok = true;
177        let case_timer = Instant::now();
178
179        // Try each depth.
180        for &(depth, expected) in &depths_to_test {
181            let result = perft(&mut board, depth);
182            if result != expected {
183                detail_lines.push(format!(
184                    "  Test #{} FAIL depth={}: expected {}, got {}",
185                    case.number, depth, expected, result
186                ));
187                ok = false;
188            }
189        }
190
191        let elapsed = case_timer.elapsed();
192        timings.push(CaseTiming {
193            number: case.number,
194            elapsed,
195        });
196
197        if ok {
198            passed += 1;
199            // Print a compact one-liner with time.
200            println!(
201                "  Test #{:<4} PASS ({} depth{}) [{:.3} s]",
202                case.number,
203                depths_to_test.len(),
204                if depths_to_test.len() == 1 { "" } else { "s" },
205                elapsed.as_secs_f64(),
206            );
207        } else {
208            failed += 1;
209        }
210    }
211
212    let total_elapsed = total_timer.elapsed();
213
214    // Print any failure detail lines.
215    if !detail_lines.is_empty() {
216        eprintln!("\n--- Failures ---");
217        for line in &detail_lines {
218            eprintln!("{line}");
219        }
220    }
221
222    // Compute summary stats.
223    let total_tests = passed + failed;
224    let fast = timings.iter().min_by(|a, b| a.elapsed.cmp(&b.elapsed));
225    let slow = timings.iter().max_by(|a, b| a.elapsed.cmp(&b.elapsed));
226    let total_time: std::time::Duration = timings.iter().map(|t| t.elapsed).sum();
227    let avg_time = if !timings.is_empty() {
228        total_time / timings.len() as u32
229    } else {
230        std::time::Duration::ZERO
231    };
232
233    // Summary block.
234    eprintln!();
235    eprintln!("--- Summary ---");
236    if let Some(f) = fast {
237        eprintln!(
238            "  Fastest:     Test #{} ({:.3} s)",
239            f.number,
240            f.elapsed.as_secs_f64()
241        );
242    }
243    if let Some(s) = slow {
244        eprintln!(
245            "  Slowest:     Test #{} ({:.3} s)",
246            s.number,
247            s.elapsed.as_secs_f64()
248        );
249    }
250    eprintln!("  Average:     {:.3} s per test", avg_time.as_secs_f64());
251    eprintln!("  Total time:  {:.3} s", total_elapsed.as_secs_f64());
252    eprintln!("  Result:      {passed}/{total_tests} passed, {failed}/{total_tests} failed");
253
254    if failed > 0 {
255        process::exit(1);
256    }
257}