Skip to content

Commit e05ebe0

Browse files
committed
perf: improve movegen, replay, stats, and validate UX
- Filter pseudo-legal moves with apply/undo on one scratch position - Reuse legal-move buffer across plies in PGN replay - Classify openings via stack UCI bytes (no coordinate Vec) - Stream stats from PgnReader; push_record_with_summary avoids duplicate summarize for JSON - is_in_check returns false if king square is missing - feat(cli): validate --verbose lists replay failures by game index - docs(readme): fix architecture paths; document --verbose - test: mixed valid/invalid PGN fixture
1 parent 8bf181c commit e05ebe0

12 files changed

Lines changed: 168 additions & 104 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ This project is **not** a top-tier competitive chess engine and does not current
7676
# Validate whether games can be reconstructed legally
7777
ply validate games.pgn
7878

79+
# Same, but print a line per failed game (replay error)
80+
ply validate games.pgn --verbose
81+
7982
# Print one-line summaries per game
8083
ply summarize games.pgn
8184

@@ -180,8 +183,8 @@ The repository is organized as a library-first core with a small command-line fr
180183
- FEN parser/serializer and validation errors
181184
- `src/movegen.rs`
182185
- pseudo-legal and legal move generation, attack detection, and state transitions
183-
- `src/pgn.rs`
184-
- PGN parsing, SAN normalization/resolution, and game reconstruction pipeline
186+
- `src/pgn/`
187+
- PGN parsing (`parse.rs`), SAN resolution (`san.rs`), and replay (`replay.rs`)
185188
- `src/stats.rs`
186189
- summary and aggregate metrics over reconstructed games
187190
- `src/export.rs`

src/cli/commands.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub struct Cli {
1414
pub enum Commands {
1515
Validate {
1616
file: PathBuf,
17+
/// Print per-game replay errors for invalid games
18+
#[arg(short, long)]
19+
verbose: bool,
1720
},
1821
Summarize {
1922
file: PathBuf,

src/cli/mod.rs

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ use std::time::Instant;
88
use ply::fen::{parse_fen, to_fen, FenError, STARTPOS_FEN};
99
use ply::movegen::generate_legal_moves;
1010
use ply::perft::{perft, perft_divide};
11-
use ply::pgn::{parse_pgn_reader, reconstruct_game, PgnError, PgnReader};
12-
use ply::stats::{AggregateStatsAccumulator, summarize_game};
11+
use ply::pgn::{reconstruct_game, PgnError, PgnReader};
12+
use ply::stats::{summarize_game, AggregateStatsAccumulator};
1313
use thiserror::Error;
1414

1515
use self::commands::{Cli, Commands};
1616
use self::render::{
1717
render_fen, render_perft, render_stats, render_summaries, render_validate, FenOutput,
18-
PerftOutput, StatsOutput, SummariesOutput, SummaryEntry, ValidateOutput,
18+
PerftOutput, StatsOutput, SummariesOutput, SummaryEntry, ValidateFailure, ValidateOutput,
1919
};
2020

2121
#[derive(Debug, Error)]
@@ -32,64 +32,78 @@ pub enum CliError {
3232

3333
pub fn run(cli: Cli) -> Result<(), CliError> {
3434
match cli.command {
35-
Commands::Validate { file } => print!("{}", render_validate(&cmd_validate(&file)?)),
35+
Commands::Validate { file, verbose } => {
36+
print!("{}", render_validate(&cmd_validate(&file, verbose)?))
37+
}
3638
Commands::Summarize { file } => print!("{}", render_summaries(&cmd_summarize(&file)?)),
3739
Commands::Stats { file, json } => print!("{}", render_stats(&cmd_stats(&file, json)?)?),
38-
Commands::Fen { fen, legal_moves } => print!("{}", render_fen(&cmd_fen(&fen, legal_moves)?)),
40+
Commands::Fen { fen, legal_moves } => {
41+
print!("{}", render_fen(&cmd_fen(&fen, legal_moves)?))
42+
}
3943
Commands::Perft { fen, depth, divide } => {
4044
print!("{}", render_perft(&cmd_perft(fen.as_deref(), depth, divide)?))
4145
}
4246
}
4347
Ok(())
4448
}
4549

46-
fn cmd_validate(file: &std::path::Path) -> Result<ValidateOutput, CliError> {
50+
fn cmd_validate(file: &std::path::Path, verbose: bool) -> Result<ValidateOutput, CliError> {
4751
let reader = BufReader::new(File::open(file)?);
4852
let mut ok = 0usize;
4953
let mut failed = 0usize;
5054
let mut total = 0usize;
51-
for game in PgnReader::new(reader) {
55+
let mut failures = Vec::new();
56+
for (idx, game) in PgnReader::new(reader).enumerate() {
5257
total += 1;
53-
match reconstruct_game(&game?) {
58+
let game = game?;
59+
match reconstruct_game(&game) {
5460
Ok(_) => ok += 1,
55-
Err(_) => failed += 1,
61+
Err(err) => {
62+
failed += 1;
63+
if verbose {
64+
failures
65+
.push(ValidateFailure { game_index: idx + 1, message: err.to_string() });
66+
}
67+
}
5668
}
5769
}
58-
Ok(ValidateOutput { validated_games: total, valid: ok, invalid: failed })
70+
Ok(ValidateOutput { validated_games: total, valid: ok, invalid: failed, failures })
5971
}
6072

6173
fn cmd_summarize(file: &std::path::Path) -> Result<SummariesOutput, CliError> {
6274
let reader = BufReader::new(File::open(file)?);
6375
let mut entries = Vec::new();
6476
for (idx, game) in PgnReader::new(reader).enumerate() {
6577
match reconstruct_game(&game?) {
66-
Ok(record) => entries.push(SummaryEntry::Valid { index: idx + 1, summary: summarize_game(&record) }),
67-
Err(err) => entries.push(SummaryEntry::Invalid { index: idx + 1, error: err.to_string() }),
78+
Ok(record) => entries
79+
.push(SummaryEntry::Valid { index: idx + 1, summary: summarize_game(&record) }),
80+
Err(err) => {
81+
entries.push(SummaryEntry::Invalid { index: idx + 1, error: err.to_string() })
82+
}
6883
}
6984
}
7085
Ok(SummariesOutput { entries })
7186
}
7287

7388
fn cmd_stats(file: &std::path::Path, json: bool) -> Result<StatsOutput, CliError> {
7489
let reader = BufReader::new(File::open(file)?);
75-
if json {
76-
let games = parse_pgn_reader(reader)?;
77-
let records = games.iter().filter_map(|g| reconstruct_game(g).ok()).collect::<Vec<_>>();
78-
let summaries = records.iter().map(summarize_game).collect::<Vec<_>>();
79-
let mut acc = AggregateStatsAccumulator::default();
80-
for record in &records {
81-
acc.push_record(record);
82-
}
83-
return Ok(StatsOutput { json: true, stats: acc.finish(), summaries });
84-
}
85-
8690
let mut acc = AggregateStatsAccumulator::default();
91+
let mut summaries = Vec::new();
92+
8793
for game in PgnReader::new(reader) {
88-
if let Ok(record) = reconstruct_game(&game?) {
89-
acc.push_record(&record);
94+
let game = game?;
95+
if let Ok(record) = reconstruct_game(&game) {
96+
if json {
97+
let summary = summarize_game(&record);
98+
acc.push_record_with_summary(&record, &summary);
99+
summaries.push(summary);
100+
} else {
101+
acc.push_record(&record);
102+
}
90103
}
91104
}
92-
Ok(StatsOutput { json: false, stats: acc.finish(), summaries: Vec::new() })
105+
106+
Ok(StatsOutput { json, stats: acc.finish(), summaries })
93107
}
94108

95109
fn cmd_fen(fen: &str, legal_moves: bool) -> Result<FenOutput, CliError> {

src/cli/render.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ use ply::stats::{AggregateStats, GameSummary};
33

44
use super::CliError;
55

6+
#[derive(Debug, Clone)]
7+
pub struct ValidateFailure {
8+
pub game_index: usize,
9+
pub message: String,
10+
}
11+
612
pub struct ValidateOutput {
713
pub validated_games: usize,
814
pub valid: usize,
915
pub invalid: usize,
16+
pub failures: Vec<ValidateFailure>,
1017
}
1118

1219
pub enum SummaryEntry {
@@ -38,10 +45,17 @@ pub struct PerftOutput {
3845
}
3946

4047
pub fn render_validate(output: &ValidateOutput) -> String {
41-
format!(
48+
let mut rendered = format!(
4249
"validated games: {}\nvalid: {}\ninvalid: {}\n",
4350
output.validated_games, output.valid, output.invalid
44-
)
51+
);
52+
if !output.failures.is_empty() {
53+
rendered.push_str("\nfailures:\n");
54+
for f in &output.failures {
55+
rendered.push_str(&format!(" game {}: {}\n", f.game_index, f.message));
56+
}
57+
}
58+
rendered
4559
}
4660

4761
pub fn render_summaries(output: &SummariesOutput) -> String {
@@ -132,21 +146,16 @@ pub fn render_stats(output: &StatsOutput) -> Result<String, CliError> {
132146
" games_with_queenside_castle: {}\n",
133147
output.stats.games_with_queenside_castle
134148
));
135-
rendered.push_str(&format!(
136-
" games_with_no_castling: {}\n",
137-
output.stats.games_with_no_castling
138-
));
149+
rendered
150+
.push_str(&format!(" games_with_no_castling: {}\n", output.stats.games_with_no_castling));
139151

140152
rendered.push_str("move_events:\n");
141153
rendered.push_str(&format!(" total_captures: {}\n", output.stats.total_captures));
142154
rendered.push_str(&format!(" average_captures: {:.2}\n", output.stats.average_captures));
143155
rendered.push_str(&format!(" total_checks: {}\n", output.stats.total_checks));
144156
rendered.push_str(&format!(" average_checks: {:.2}\n", output.stats.average_checks));
145157
rendered.push_str(&format!(" total_promotions: {}\n", output.stats.total_promotions));
146-
rendered.push_str(&format!(
147-
" average_promotions: {:.2}\n",
148-
output.stats.average_promotions
149-
));
158+
rendered.push_str(&format!(" average_promotions: {:.2}\n", output.stats.average_promotions));
150159
Ok(rendered)
151160
}
152161

src/movegen.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::board::{CastleSide, CastlingRights, ChessMove, Color, Piece, PieceKind, Position, Square};
1+
use crate::board::{
2+
CastleSide, CastlingRights, ChessMove, Color, Piece, PieceKind, Position, Square,
3+
};
24

35
#[derive(Debug, Clone, Copy)]
46
pub struct Undo {
@@ -23,17 +25,21 @@ pub fn generate_legal_moves_into(position: &Position, out: &mut Vec<ChessMove>)
2325
out.clear();
2426
let mut pseudo = Vec::with_capacity(64);
2527
generate_pseudo_legal_moves_into(position, &mut pseudo);
28+
let mut scratch = position.clone();
29+
let side = position.side_to_move;
2630
for mv in pseudo {
27-
let mut next = position.clone();
28-
apply_move(&mut next, mv);
29-
if !is_in_check(&next, position.side_to_move) {
31+
let undo = apply_move_with_undo(&mut scratch, mv);
32+
if !is_in_check(&scratch, side) {
3033
out.push(mv);
3134
}
35+
undo_move(&mut scratch, mv, undo);
3236
}
3337
}
3438

3539
pub fn is_in_check(position: &Position, color: Color) -> bool {
36-
let king_sq = position.king_square(color).expect("valid positions must track kings");
40+
let Some(king_sq) = position.king_square(color) else {
41+
return false;
42+
};
3743
is_square_attacked(position, king_sq, color.opposite())
3844
}
3945

@@ -350,8 +356,12 @@ fn push_castling_moves(position: &Position, from: Square, piece: Piece, out: &mu
350356
return;
351357
}
352358
let (rank, king_side_ok, queen_side_ok) = match piece.color {
353-
Color::White => (0u8, position.castling.white_king_side, position.castling.white_queen_side),
354-
Color::Black => (7u8, position.castling.black_king_side, position.castling.black_queen_side),
359+
Color::White => {
360+
(0u8, position.castling.white_king_side, position.castling.white_queen_side)
361+
}
362+
Color::Black => {
363+
(7u8, position.castling.black_king_side, position.castling.black_queen_side)
364+
}
355365
};
356366

357367
if from != Square::from_coords(4, rank).expect("e-file") {

src/opening.rs

Lines changed: 38 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::board::ChessMove;
1+
use crate::board::{ChessMove, PieceKind};
22

33
#[derive(Debug, Clone, PartialEq, Eq)]
44
pub struct OpeningInfo {
@@ -18,84 +18,77 @@ const OPENINGS: &[OpeningLine] = &[
1818
line: &["e2e4", "e7e5", "g1f3", "b8c6", "f1b5"],
1919
},
2020
OpeningLine {
21-
info: OpeningInfo {
22-
eco: "C50",
23-
opening: "Italian Game",
24-
variation: None,
25-
},
21+
info: OpeningInfo { eco: "C50", opening: "Italian Game", variation: None },
2622
line: &["e2e4", "e7e5", "g1f3", "b8c6", "f1c4"],
2723
},
2824
OpeningLine {
29-
info: OpeningInfo {
30-
eco: "B20",
31-
opening: "Sicilian Defense",
32-
variation: None,
33-
},
25+
info: OpeningInfo { eco: "B20", opening: "Sicilian Defense", variation: None },
3426
line: &["e2e4", "c7c5"],
3527
},
3628
OpeningLine {
37-
info: OpeningInfo {
38-
eco: "C00",
39-
opening: "French Defense",
40-
variation: None,
41-
},
29+
info: OpeningInfo { eco: "C00", opening: "French Defense", variation: None },
4230
line: &["e2e4", "e7e6"],
4331
},
4432
OpeningLine {
45-
info: OpeningInfo {
46-
eco: "B10",
47-
opening: "Caro-Kann Defense",
48-
variation: None,
49-
},
33+
info: OpeningInfo { eco: "B10", opening: "Caro-Kann Defense", variation: None },
5034
line: &["e2e4", "c7c6"],
5135
},
5236
OpeningLine {
53-
info: OpeningInfo {
54-
eco: "D06",
55-
opening: "Queen's Gambit",
56-
variation: None,
57-
},
37+
info: OpeningInfo { eco: "D06", opening: "Queen's Gambit", variation: None },
5838
line: &["d2d4", "d7d5", "c2c4"],
5939
},
6040
OpeningLine {
61-
info: OpeningInfo {
62-
eco: "D20",
63-
opening: "Queen's Gambit Accepted",
64-
variation: None,
65-
},
41+
info: OpeningInfo { eco: "D20", opening: "Queen's Gambit Accepted", variation: None },
6642
line: &["d2d4", "d7d5", "c2c4", "d5c4"],
6743
},
6844
OpeningLine {
69-
info: OpeningInfo {
70-
eco: "D30",
71-
opening: "Queen's Gambit Declined",
72-
variation: None,
73-
},
45+
info: OpeningInfo { eco: "D30", opening: "Queen's Gambit Declined", variation: None },
7446
line: &["d2d4", "d7d5", "c2c4", "e7e6"],
7547
},
7648
OpeningLine {
7749
info: OpeningInfo { eco: "D10", opening: "Slav Defense", variation: None },
7850
line: &["d2d4", "d7d5", "c2c4", "c7c6"],
7951
},
8052
OpeningLine {
81-
info: OpeningInfo {
82-
eco: "A00",
83-
opening: "Van't Kruijs Opening",
84-
variation: None,
85-
},
53+
info: OpeningInfo { eco: "A00", opening: "Van't Kruijs Opening", variation: None },
8654
line: &["e2e3"],
8755
},
8856
];
8957

9058
pub fn classify_opening(plies: &[ChessMove]) -> Option<OpeningInfo> {
91-
let coordinates = plies.iter().map(|mv| mv.to_coordinate()).collect::<Vec<_>>();
9259
OPENINGS
9360
.iter()
94-
.filter(|opening| has_prefix(&coordinates, opening.line))
61+
.filter(|opening| has_uci_prefix(plies, opening.line))
9562
.max_by_key(|opening| opening.line.len())
9663
.map(|opening| opening.info.clone())
9764
}
9865

99-
fn has_prefix(moves: &[String], prefix: &[&str]) -> bool {
100-
moves.len() >= prefix.len() && moves.iter().zip(prefix.iter()).all(|(mv, expected)| mv == expected)
66+
/// Compare against UCI-like strings (`e2e4`, optional promotion suffix).
67+
fn move_matches_uci(mv: ChessMove, uci: &str) -> bool {
68+
let mut buf = [0u8; 6];
69+
let n = write_move_uci(mv, &mut buf);
70+
uci.len() == n && uci.as_bytes() == &buf[..n]
71+
}
72+
73+
fn write_move_uci(mv: ChessMove, out: &mut [u8; 6]) -> usize {
74+
out[0] = b'a' + mv.from.file();
75+
out[1] = b'1' + mv.from.rank();
76+
out[2] = b'a' + mv.to.file();
77+
out[3] = b'1' + mv.to.rank();
78+
if let Some(p) = mv.promotion {
79+
out[4] = match p {
80+
PieceKind::Knight => b'n',
81+
PieceKind::Bishop => b'b',
82+
PieceKind::Rook => b'r',
83+
PieceKind::Queen => b'q',
84+
PieceKind::Pawn | PieceKind::King => b'q',
85+
};
86+
return 5;
87+
}
88+
4
89+
}
90+
91+
fn has_uci_prefix(plies: &[ChessMove], prefix: &[&str]) -> bool {
92+
plies.len() >= prefix.len()
93+
&& plies.iter().zip(prefix.iter()).all(|(mv, expected)| move_matches_uci(*mv, expected))
10194
}

0 commit comments

Comments
 (0)