diff --git a/Cargo.lock b/Cargo.lock index 7f604ca..08aad7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -749,6 +749,15 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1175,6 +1184,7 @@ dependencies = [ "chrono-tz", "indexmap", "indoc", + "inventory", "libc", "memoffset", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 7ded532..69b350c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ crate-type = ["lib", "cdylib"] # lib is needed for the native benchmark [dependencies] -pyo3 = "0.27.2" +pyo3 = { version = "0.27.2", features = ["multiple-pymethods"] } shakmaty = "0.30" pgn-reader = "0.29.0" nom = "8.0" diff --git a/src/lib.rs b/src/lib.rs index 2a6f5a9..7c58f59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,59 @@ mod visitor; use python_bindings::{ChunkData, ParsedGames, ParsedGamesIter, PyChunkView, PyGameView}; pub use visitor::{Buffers, ParseConfig, parse_game_to_buffers}; +/// Shared parallel parsing logic for a slice of PGN strings. +/// +/// Both `parse_games` and `parse_games_from_strings` delegate here after +/// extracting their `&str` slices from different input types. +fn parse_str_slices( + py: Python<'_>, + slices: &[&str], + num_threads: usize, + chunk_multiplier: usize, + config: &ParseConfig, +) -> PyResult { + let n_games = slices.len(); + if n_games == 0 { + let empty_chunk = buffers_to_chunk_data(py, Buffers::default())?; + return build_parsed_games(py, vec![empty_chunk]); + } + + let thread_pool = ThreadPoolBuilder::new() + .num_threads(num_threads) + .build() + .map_err(|e| { + PyErr::new::(format!( + "Failed to build thread pool: {}", + e + )) + })?; + + // num_chunks = num_threads * chunk_multiplier (e.g., 16 threads * 1 = 16 chunks) + let num_chunks = num_threads * chunk_multiplier; + let chunk_size = ((n_games + num_chunks - 1) / num_chunks).max(1); + let moves_per_game = 70; + + let chunk_results: Vec = thread_pool.install(|| { + slices + .par_chunks(chunk_size) + .map(|chunk| { + let mut buffers = Buffers::with_capacity(chunk_size, moves_per_game, config); + for &pgn in chunk { + let _ = parse_game_to_buffers(pgn, &mut buffers, config); + } + buffers + }) + .collect() + }); + + let chunk_data_vec: Vec = chunk_results + .into_iter() + .map(|buf| buffers_to_chunk_data(py, buf)) + .collect::>>()?; + + build_parsed_games(py, chunk_data_vec) +} + /// Parse games from Arrow chunked array into a chunked ParsedGames container. /// /// This implementation uses explicit chunking with a fixed number of chunks @@ -43,11 +96,7 @@ fn parse_games( let chunk_multiplier = chunk_multiplier.unwrap_or(1); // Extract PGN strings from Arrow chunks - let mut num_elements = 0; - for chunk in pgn_chunked_array.chunks() { - num_elements += chunk.len(); - } - + let num_elements: usize = pgn_chunked_array.chunks().iter().map(|c| c.len()).sum(); let mut pgn_str_slices: Vec<&str> = Vec::with_capacity(num_elements); for chunk in pgn_chunked_array.chunks() { if let Some(string_array) = chunk.as_any().downcast_ref::() { @@ -70,56 +119,7 @@ fn parse_games( } } - let n_games = pgn_str_slices.len(); - if n_games == 0 { - let empty_chunk = buffers_to_chunk_data(py, Buffers::default())?; - return build_parsed_games(py, vec![empty_chunk]); - } - - // Build thread pool - let thread_pool = ThreadPoolBuilder::new() - .num_threads(num_threads) - .build() - .map_err(|e| { - PyErr::new::(format!( - "Failed to build thread pool: {}", - e - )) - })?; - - // Calculate chunk size for explicit chunking. - // num_chunks = num_threads * chunk_multiplier (e.g., 16 threads * 1 = 16 chunks) - let num_chunks = num_threads * chunk_multiplier; - let chunk_size = (n_games + num_chunks - 1) / num_chunks; // ceiling division - let chunk_size = chunk_size.max(1); // ensure at least 1 game per chunk - - // Estimate capacity per chunk - let games_per_chunk = chunk_size; - let moves_per_game = 70; - - // Parse in parallel using par_chunks for explicit, fixed-size chunking. - // This creates exactly ceil(n_games / chunk_size) Buffers instances, - // avoiding the allocation storm from Rayon's dynamic work-stealing. - let chunk_results: Vec = thread_pool.install(|| { - pgn_str_slices - .par_chunks(chunk_size) - .map(|chunk| { - let mut buffers = Buffers::with_capacity(games_per_chunk, moves_per_game, &config); - for &pgn in chunk { - let _ = parse_game_to_buffers(pgn, &mut buffers, &config); - } - buffers - }) - .collect() - }); - - // Convert each Buffers to ChunkData (numpy arrays) — no merge needed - let chunk_data_vec: Vec = chunk_results - .into_iter() - .map(|buf| buffers_to_chunk_data(py, buf)) - .collect::>>()?; - - build_parsed_games(py, chunk_data_vec) + parse_str_slices(py, &pgn_str_slices, num_threads, chunk_multiplier, &config) } /// Convert a single Buffers into a ChunkData with NumPy arrays. @@ -169,15 +169,9 @@ fn buffers_to_chunk_data(py: Python<'_>, buffers: Buffers) -> PyResult 0 { - is_insufficient_array - .reshape([n_games, 2]) - .map_err(|e| PyErr::new::(e.to_string()))? - } else { - is_insufficient_array - .reshape([0, 2]) - .map_err(|e| PyErr::new::(e.to_string()))? - }; + let is_insufficient_reshaped = is_insufficient_array + .reshape([n_games, 2]) + .map_err(|e| PyErr::new::(e.to_string()))?; let legal_move_count_array = PyArray1::from_vec(py, buffers.legal_move_count); let valid_array = PyArray1::from_vec(py, buffers.valid); @@ -207,6 +201,7 @@ fn buffers_to_chunk_data(py: Python<'_>, buffers: Buffers) -> PyResult(format!( - "Failed to build thread pool: {}", - e - )) - })?; - - let num_chunks = num_threads; - let chunk_size = (n_games + num_chunks - 1) / num_chunks; - let chunk_size = chunk_size.max(1); - let games_per_chunk = chunk_size; - let moves_per_game = 70; - - let chunk_results: Vec = thread_pool.install(|| { - pgns.par_chunks(chunk_size) - .map(|chunk| { - let mut buffers = Buffers::with_capacity(games_per_chunk, moves_per_game, &config); - for pgn in chunk { - let _ = parse_game_to_buffers(pgn, &mut buffers, &config); - } - buffers - }) - .collect() - }); - - let chunk_data_vec: Vec = chunk_results - .into_iter() - .map(|buf| buffers_to_chunk_data(py, buf)) - .collect::>>()?; - - build_parsed_games(py, chunk_data_vec) + let str_slices: Vec<&str> = pgns.iter().map(|s| s.as_str()).collect(); + parse_str_slices(py, &str_slices, num_threads, 1, &config) } /// Parser for chess PGN notation diff --git a/src/python_bindings.rs b/src/python_bindings.rs index 60d1d93..740f2ac 100644 --- a/src/python_bindings.rs +++ b/src/python_bindings.rs @@ -30,6 +30,7 @@ pub struct ChunkData { pub valid: Py, // (N_games,) bool pub headers: Vec>, pub outcome: Vec>, // Per-game: "White", "Black", "Draw", "Unknown", or None + pub parse_errors: Vec>, // Per-game: None if valid, Some(msg) if not // Optional: raw text comments (per-move), only populated when store_comments=true pub comments: Vec>, @@ -47,11 +48,88 @@ pub struct ChunkData { pub num_legal_moves: usize, } +// --------------------------------------------------------------------------- +// Helper: searchsorted-based index mapping (used by position_to_game / move_to_game) +// --------------------------------------------------------------------------- + +fn index_to_game<'py>( + py: Python<'py>, + offsets: &Py, + indices: &Bound<'py, PyAny>, +) -> PyResult>> { + let offsets = offsets.bind(py); + let offsets: &Bound<'_, PyArray1> = offsets.cast()?; + + let numpy = py.import("numpy")?; + + let int64_dtype = numpy.getattr("int64")?; + let indices = numpy.call_method1("asarray", (indices,))?.call_method( + "astype", + (int64_dtype,), + Some(&[("copy", false)].into_py_dict(py)?), + )?; + + let len = offsets.len()?; + let slice_obj = PySlice::new(py, 0, (len - 1) as isize, 1); + let offsets_slice = offsets.call_method1("__getitem__", (slice_obj,))?; + + let result = numpy.call_method1( + "searchsorted", + ( + offsets_slice, + indices, + pyo3::types::PyString::new(py, "right"), + ), + )?; + + let one = 1i64.into_pyobject(py)?; + let result = result.call_method1("__sub__", (one,))?; + + Ok(result.extract()?) +} + +// --------------------------------------------------------------------------- +// Helper: UCI string formatting +// --------------------------------------------------------------------------- + +const FILES: [char; 8] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; +const RANKS: [char; 8] = ['1', '2', '3', '4', '5', '6', '7', '8']; +const PROMO_CHARS: [char; 6] = ['_', '_', 'n', 'b', 'r', 'q']; // index 2=N, 3=B, 4=R, 5=Q + +fn format_uci(from_sq: u8, to_sq: u8, promo: i8) -> String { + let mut uci = format!( + "{}{}{}{}", + FILES[(from_sq % 8) as usize], + RANKS[(from_sq / 8) as usize], + FILES[(to_sq % 8) as usize], + RANKS[(to_sq / 8) as usize] + ); + if promo >= 0 && (promo as usize) < PROMO_CHARS.len() { + uci.push(PROMO_CHARS[promo as usize]); + } + uci +} + +// --------------------------------------------------------------------------- +// ParsedGames +// --------------------------------------------------------------------------- + /// Chunked container for parsed chess games, optimized for ML training. /// -/// Internally stores data in multiple chunks (one per parsing thread) to -/// avoid the cost of merging. Per-game access is O(log(num_chunks)) via -/// binary search on precomputed boundaries. +/// Stores parsed PGN data across multiple internal chunks (one per parsing +/// thread). Supports integer indexing, slicing, and iteration to access +/// individual games as ``PyGameView`` objects. +/// +/// Properties: +/// num_games: Total number of games. +/// num_moves: Total half-moves across all games. +/// num_positions: Total board positions recorded. +/// num_chunks: Number of internal chunks. +/// chunks: List of ``PyChunkView`` for direct array access. +/// +/// Methods: +/// position_to_game(indices): Map position indices to game indices. +/// move_to_game(indices): Map move indices to game indices. #[pyclass] pub struct ParsedGames { pub chunks: Vec, @@ -60,7 +138,9 @@ pub struct ParsedGames { // game_boundaries[i] = total games in chunks 0..i // So game_boundaries = [0, chunk0.num_games, chunk0+chunk1, ..., total_games] pub game_boundaries: Vec, + #[allow(dead_code)] pub move_boundaries: Vec, + #[allow(dead_code)] pub position_boundaries: Vec, pub total_games: usize, @@ -124,6 +204,16 @@ impl ParsedGames { self.total_games } + fn __repr__(&self) -> String { + format!( + "", + self.total_games, + self.total_moves, + self.total_positions, + self.chunks.len() + ) + } + fn __getitem__(slf: Py, py: Python<'_>, idx: &Bound<'_, PyAny>) -> PyResult> { let n_games = slf.borrow(py).total_games; @@ -196,300 +286,142 @@ impl ParsedGames { /// Map position indices to game indices. /// - /// Useful after shuffling/sampling positions to look up game metadata. + /// Given an array of indices into the global position space, returns + /// an array of the corresponding game indices. Useful after shuffling + /// or sampling positions to look up game metadata. /// /// Args: - /// position_indices: Array of indices into the global position space. + /// position_indices: Array of position indices. /// Accepts any integer dtype; int64 is optimal (avoids conversion). /// /// Returns: - /// Array of game indices (same shape as input) + /// numpy.ndarray[int64]: Game indices (same shape as input). fn position_to_game<'py>( &self, py: Python<'py>, position_indices: &Bound<'py, PyAny>, ) -> PyResult>> { - let offsets = self.global_position_offsets.bind(py); - let offsets: &Bound<'_, PyArray1> = offsets.cast()?; - - let numpy = py.import("numpy")?; - - let int64_dtype = numpy.getattr("int64")?; - let position_indices = numpy - .call_method1("asarray", (position_indices,))? - .call_method( - "astype", - (int64_dtype,), - Some(&[("copy", false)].into_py_dict(py)?), - )?; - - let len = offsets.len()?; - let slice_obj = PySlice::new(py, 0, (len - 1) as isize, 1); - let offsets_slice = offsets.call_method1("__getitem__", (slice_obj,))?; - - let result = numpy.call_method1( - "searchsorted", - ( - offsets_slice, - position_indices, - pyo3::types::PyString::new(py, "right"), - ), - )?; - - let one = 1i64.into_pyobject(py)?; - let result = result.call_method1("__sub__", (one,))?; - - Ok(result.extract()?) + index_to_game(py, &self.global_position_offsets, position_indices) } /// Map move indices to game indices. /// + /// Given an array of indices into the global move space, returns + /// an array of the corresponding game indices. + /// /// Args: - /// move_indices: Array of indices into the global move space. + /// move_indices: Array of move indices. /// Accepts any integer dtype; int64 is optimal (avoids conversion). /// /// Returns: - /// Array of game indices (same shape as input) + /// numpy.ndarray[int64]: Game indices (same shape as input). fn move_to_game<'py>( &self, py: Python<'py>, move_indices: &Bound<'py, PyAny>, ) -> PyResult>> { - let offsets = self.global_move_offsets.bind(py); - let offsets: &Bound<'_, PyArray1> = offsets.cast()?; - - let numpy = py.import("numpy")?; - - let int64_dtype = numpy.getattr("int64")?; - let move_indices = numpy - .call_method1("asarray", (move_indices,))? - .call_method( - "astype", - (int64_dtype,), - Some(&[("copy", false)].into_py_dict(py)?), - )?; - - let len = offsets.len()?; - let slice_obj = PySlice::new(py, 0, (len - 1) as isize, 1); - let offsets_slice = offsets.call_method1("__getitem__", (slice_obj,))?; - - let result = numpy.call_method1( - "searchsorted", - ( - offsets_slice, - move_indices, - pyo3::types::PyString::new(py, "right"), - ), - )?; - - let one = 1i64.into_pyobject(py)?; - let result = result.call_method1("__sub__", (one,))?; - - Ok(result.extract()?) + index_to_game(py, &self.global_move_offsets, move_indices) } } -/// Escape hatch: view into a single chunk's raw numpy arrays. +// --------------------------------------------------------------------------- +// PyChunkView — macros + impl +// --------------------------------------------------------------------------- + +/// Lightweight view into a single parsing chunk's raw numpy arrays. +/// +/// Access via ``parsed_games.chunks[i]``. Each chunk corresponds to one +/// parsing thread's output. All numpy array properties are zero-copy +/// references into the parent data. /// -/// Access via `parsed_games.chunks[i]`. Each chunk corresponds to one -/// parsing thread's output. Use this for advanced access patterns like -/// manual concatenation or custom batching. +/// Use this for advanced access patterns like manual concatenation, +/// custom batching, or direct array-level ML pipelines. #[pyclass] pub struct PyChunkView { parent: Py, chunk_idx: usize, } -#[pymethods] -impl PyChunkView { - #[getter] - fn num_games(&self, py: Python<'_>) -> usize { - self.parent.borrow(py).chunks[self.chunk_idx].num_games - } - - #[getter] - fn num_moves(&self, py: Python<'_>) -> usize { - self.parent.borrow(py).chunks[self.chunk_idx].num_moves - } - - #[getter] - fn num_positions(&self, py: Python<'_>) -> usize { - self.parent.borrow(py).chunks[self.chunk_idx].num_positions - } - - #[getter] - fn boards(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .boards - .clone_ref(py) - } - - #[getter] - fn castling(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .castling - .clone_ref(py) - } - - #[getter] - fn en_passant(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .en_passant - .clone_ref(py) - } - - #[getter] - fn halfmove_clock(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .halfmove_clock - .clone_ref(py) - } - - #[getter] - fn turn(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .turn - .clone_ref(py) - } - - #[getter] - fn from_squares(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .from_squares - .clone_ref(py) - } - - #[getter] - fn to_squares(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .to_squares - .clone_ref(py) - } - - #[getter] - fn promotions(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .promotions - .clone_ref(py) - } - - #[getter] - fn clocks(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .clocks - .clone_ref(py) - } - - #[getter] - fn evals(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .evals - .clone_ref(py) - } - - #[getter] - fn move_offsets(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .move_offsets - .clone_ref(py) - } - - #[getter] - fn position_offsets(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .position_offsets - .clone_ref(py) - } - - #[getter] - fn is_checkmate(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .is_checkmate - .clone_ref(py) - } - - #[getter] - fn is_stalemate(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .is_stalemate - .clone_ref(py) - } - - #[getter] - fn is_insufficient(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .is_insufficient - .clone_ref(py) - } - - #[getter] - fn legal_move_count(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .legal_move_count - .clone_ref(py) - } - - #[getter] - fn valid(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .valid - .clone_ref(py) - } - - #[getter] - fn headers(&self, py: Python<'_>) -> Vec> { - self.parent.borrow(py).chunks[self.chunk_idx] - .headers - .clone() - } - - #[getter] - fn outcome(&self, py: Python<'_>) -> Vec> { - self.parent.borrow(py).chunks[self.chunk_idx] - .outcome - .clone() - } - - /// Raw text comments per move (only populated when store_comments=true). - #[getter] - fn comments(&self, py: Python<'_>) -> Vec> { - self.parent.borrow(py).chunks[self.chunk_idx] - .comments - .clone() - } - - /// Legal move from-squares for all positions in this chunk. - #[getter] - fn legal_move_from_squares(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .legal_move_from_squares - .clone_ref(py) - } +/// Generate a `#[pymethods]` block with numpy-array getters for PyChunkView. +macro_rules! chunk_array_getters { + ($($name:ident),+ $(,)?) => { + #[pymethods] + impl PyChunkView { + $( + #[getter] + fn $name(&self, py: Python<'_>) -> Py { + self.parent.borrow(py).chunks[self.chunk_idx].$name.clone_ref(py) + } + )+ + } + }; +} - /// Legal move to-squares for all positions in this chunk. - #[getter] - fn legal_move_to_squares(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .legal_move_to_squares - .clone_ref(py) - } +/// Generate a `#[pymethods]` block with Vec-cloning getters for PyChunkView. +macro_rules! chunk_vec_getters { + ($($name:ident -> $ret:ty),+ $(,)?) => { + #[pymethods] + impl PyChunkView { + $( + #[getter] + fn $name(&self, py: Python<'_>) -> $ret { + self.parent.borrow(py).chunks[self.chunk_idx].$name.clone() + } + )+ + } + }; +} - /// Legal move promotions for all positions in this chunk. - #[getter] - fn legal_move_promotions(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .legal_move_promotions - .clone_ref(py) - } +/// Generate a `#[pymethods]` block with scalar getters for PyChunkView. +macro_rules! chunk_scalar_getters { + ($($name:ident),+ $(,)?) => { + #[pymethods] + impl PyChunkView { + $( + #[getter] + fn $name(&self, py: Python<'_>) -> usize { + self.parent.borrow(py).chunks[self.chunk_idx].$name + } + )+ + } + }; +} - /// CSR offsets for legal moves (per-position). Length = num_positions + 1. - #[getter] - fn legal_move_offsets(&self, py: Python<'_>) -> Py { - self.parent.borrow(py).chunks[self.chunk_idx] - .legal_move_offsets - .clone_ref(py) - } +chunk_scalar_getters!(num_games, num_moves, num_positions); + +chunk_array_getters!( + boards, + castling, + en_passant, + halfmove_clock, + turn, + from_squares, + to_squares, + promotions, + clocks, + evals, + move_offsets, + position_offsets, + is_checkmate, + is_stalemate, + is_insufficient, + legal_move_count, + valid, + legal_move_from_squares, + legal_move_to_squares, + legal_move_promotions, + legal_move_offsets, +); + +chunk_vec_getters!( + headers -> Vec>, + outcome -> Vec>, + parse_errors -> Vec>, + comments -> Vec>, +); +#[pymethods] +impl PyChunkView { fn __repr__(&self, py: Python<'_>) -> String { let borrowed = self.parent.borrow(py); let chunk = &borrowed.chunks[self.chunk_idx]; @@ -500,6 +432,10 @@ impl PyChunkView { } } +// --------------------------------------------------------------------------- +// ParsedGamesIter +// --------------------------------------------------------------------------- + /// Iterator over games in a ParsedGames result. #[pyclass] pub struct ParsedGamesIter { @@ -524,12 +460,29 @@ impl ParsedGamesIter { } } -/// Zero-copy view into a single game's data within a ParsedGames result. +// --------------------------------------------------------------------------- +// PyGameView — macros + impl +// --------------------------------------------------------------------------- + +/// Zero-copy view into a single game within a ParsedGames result. +/// +/// Provides access to board positions, moves, metadata, and annotations +/// for one game. All array properties return numpy array slices (views, +/// not copies) into the parent chunk's data. +/// +/// Board encoding: +/// Piece values: 0=empty, 1=P, 2=N, 3=B, 4=R, 5=Q, 6=K (white), +/// 7=p, 8=n, 9=b, 10=r, 11=q, 12=k (black). +/// +/// Square indexing: +/// a1=0, b1=1, ..., h1=7, a2=8, ..., h8=63. +/// rank = square // 8, file = square % 8. /// -/// Board indexing note: Boards use square indexing (a1=0, h8=63). -/// To convert to rank/file: -/// rank = square // 8 -/// file = square % 8 +/// Move encoding: +/// from_squares / to_squares: source and destination square indices. +/// promotions: -1=none, 2=N, 3=B, 4=R, 5=Q. +/// clocks: remaining time in seconds (NaN if missing). +/// evals: engine evaluation in pawns (NaN if missing). #[pyclass] pub struct PyGameView { data: Py, @@ -591,6 +544,47 @@ impl PyGameView { } } +/// Generate a `#[pymethods]` block with position-sliced getters for PyGameView. +macro_rules! game_view_pos_getters { + ($($name:ident),+ $(,)?) => { + #[pymethods] + impl PyGameView { + $( + #[getter] + fn $name<'py>(&self, py: Python<'py>) -> PyResult> { + let borrowed = self.data.borrow(py); + let arr = borrowed.chunks[self.chunk_idx].$name.bind(py); + let slice_obj = PySlice::new(py, self.pos_start as isize, self.pos_end as isize, 1); + let slice = arr.call_method1("__getitem__", (slice_obj,))?; + Ok(slice.unbind()) + } + )+ + } + }; +} + +/// Generate a `#[pymethods]` block with move-sliced getters for PyGameView. +macro_rules! game_view_move_getters { + ($($name:ident),+ $(,)?) => { + #[pymethods] + impl PyGameView { + $( + #[getter] + fn $name<'py>(&self, py: Python<'py>) -> PyResult> { + let borrowed = self.data.borrow(py); + let arr = borrowed.chunks[self.chunk_idx].$name.bind(py); + let slice_obj = PySlice::new(py, self.move_start as isize, self.move_end as isize, 1); + let slice = arr.call_method1("__getitem__", (slice_obj,))?; + Ok(slice.unbind()) + } + )+ + } + }; +} + +game_view_pos_getters!(boards, castling, en_passant, halfmove_clock, turn); +game_view_move_getters!(from_squares, to_squares, promotions, clocks, evals); + #[pymethods] impl PyGameView { /// Number of moves in this game. @@ -604,18 +598,6 @@ impl PyGameView { self.pos_end - self.pos_start } - // === Board state views === - - /// Board positions, shape (num_positions, 8, 8). - #[getter] - fn boards<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let boards = borrowed.chunks[self.chunk_idx].boards.bind(py); - let slice_obj = PySlice::new(py, self.pos_start as isize, self.pos_end as isize, 1); - let slice = boards.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - /// Initial board position, shape (8, 8). #[getter] fn initial_board<'py>(&self, py: Python<'py>) -> PyResult> { @@ -634,98 +616,6 @@ impl PyGameView { Ok(slice.unbind()) } - /// Castling rights [K,Q,k,q], shape (num_positions, 4). - #[getter] - fn castling<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].castling.bind(py); - let slice_obj = PySlice::new(py, self.pos_start as isize, self.pos_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - /// En passant file (-1 if none), shape (num_positions,). - #[getter] - fn en_passant<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].en_passant.bind(py); - let slice_obj = PySlice::new(py, self.pos_start as isize, self.pos_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - /// Halfmove clock, shape (num_positions,). - #[getter] - fn halfmove_clock<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].halfmove_clock.bind(py); - let slice_obj = PySlice::new(py, self.pos_start as isize, self.pos_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - /// Side to move (True=white), shape (num_positions,). - #[getter] - fn turn<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].turn.bind(py); - let slice_obj = PySlice::new(py, self.pos_start as isize, self.pos_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - // === Move views === - - /// From squares, shape (num_moves,). - #[getter] - fn from_squares<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].from_squares.bind(py); - let slice_obj = PySlice::new(py, self.move_start as isize, self.move_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - /// To squares, shape (num_moves,). - #[getter] - fn to_squares<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].to_squares.bind(py); - let slice_obj = PySlice::new(py, self.move_start as isize, self.move_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - /// Promotions (-1=none, 2=N, 3=B, 4=R, 5=Q), shape (num_moves,). - #[getter] - fn promotions<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].promotions.bind(py); - let slice_obj = PySlice::new(py, self.move_start as isize, self.move_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - /// Clock times in seconds (NaN if missing), shape (num_moves,). - #[getter] - fn clocks<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].clocks.bind(py); - let slice_obj = PySlice::new(py, self.move_start as isize, self.move_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - - /// Engine evals (NaN if missing), shape (num_moves,). - #[getter] - fn evals<'py>(&self, py: Python<'py>) -> PyResult> { - let borrowed = self.data.borrow(py); - let arr = borrowed.chunks[self.chunk_idx].evals.bind(py); - let slice_obj = PySlice::new(py, self.move_start as isize, self.move_end as isize, 1); - let slice = arr.call_method1("__getitem__", (slice_obj,))?; - Ok(slice.unbind()) - } - // === Per-game metadata === /// Raw PGN headers as dict. @@ -814,9 +704,35 @@ impl PyGameView { /// Whether the game is over (checkmate, stalemate, or both sides have insufficient material). #[getter] fn is_game_over(&self, py: Python<'_>) -> PyResult { - let checkmate = self.is_checkmate(py)?; - let stalemate = self.is_stalemate(py)?; - let (insuf_white, insuf_black) = self.is_insufficient(py)?; + let borrowed = self.data.borrow(py); + let chunk = &borrowed.chunks[self.chunk_idx]; + + let cm_arr = chunk.is_checkmate.bind(py); + let cm_arr: &Bound<'_, PyArray1> = cm_arr.cast()?; + let checkmate = cm_arr + .readonly() + .as_slice()? + .get(self.local_idx) + .copied() + .unwrap_or(false); + + let sm_arr = chunk.is_stalemate.bind(py); + let sm_arr: &Bound<'_, PyArray1> = sm_arr.cast()?; + let stalemate = sm_arr + .readonly() + .as_slice()? + .get(self.local_idx) + .copied() + .unwrap_or(false); + + let ins_arr = chunk.is_insufficient.bind(py); + let ins_arr: &Bound<'_, PyArray2> = ins_arr.cast()?; + let ins_slice = ins_arr.readonly(); + let ins_slice = ins_slice.as_slice()?; + let base = self.local_idx * 2; + let insuf_white = ins_slice.get(base).copied().unwrap_or(false); + let insuf_black = ins_slice.get(base + 1).copied().unwrap_or(false); + Ok(checkmate || stalemate || (insuf_white && insuf_black)) } @@ -827,6 +743,13 @@ impl PyGameView { Ok(borrowed.chunks[self.chunk_idx].outcome[self.local_idx].clone()) } + /// Parse error message if the game failed to parse, or None if valid. + #[getter] + fn parse_error(&self, py: Python<'_>) -> PyResult> { + let borrowed = self.data.borrow(py); + Ok(borrowed.chunks[self.chunk_idx].parse_errors[self.local_idx].clone()) + } + /// Raw text comments per move (only populated when store_comments=true). /// Returns list[str | None] of length num_moves. #[getter] @@ -840,14 +763,13 @@ impl PyGameView { } /// Legal moves at each position in this game. - /// Returns list of lists: [[from, to, promotion], ...] per position. + /// Returns list of lists: [(from, to, promotion), ...] per position. /// Only populated when store_legal_moves=true. #[getter] fn legal_moves(&self, py: Python<'_>) -> PyResult>> { let borrowed = self.data.borrow(py); let chunk = &borrowed.chunks[self.chunk_idx]; - // Check if legal moves were stored if chunk.num_legal_moves == 0 { return Ok(Vec::new()); } @@ -887,7 +809,7 @@ impl PyGameView { // === Convenience methods === - /// Get UCI string for move at index. + /// Get UCI string for move at index (e.g. "e2e4", "a7a8q"). fn move_uci(&self, py: Python<'_>, move_idx: usize) -> PyResult { if move_idx >= self.move_end - self.move_start { return Err(pyo3::exceptions::PyIndexError::new_err(format!( @@ -906,55 +828,37 @@ impl PyGameView { let promo_arr = chunk.promotions.bind(py); let promo_arr: &Bound<'_, PyArray1> = promo_arr.cast()?; - let from_ro = from_arr.readonly(); - let to_ro = to_arr.readonly(); - let promo_ro = promo_arr.readonly(); - - let from_slice = from_ro.as_slice()?; - let to_slice = to_ro.as_slice()?; - let promo_slice = promo_ro.as_slice()?; - let abs_idx = self.move_start + move_idx; - let from_sq = from_slice - .get(abs_idx) - .copied() - .ok_or_else(|| pyo3::exceptions::PyIndexError::new_err("Invalid move index"))?; - let to_sq = to_slice - .get(abs_idx) - .copied() - .ok_or_else(|| pyo3::exceptions::PyIndexError::new_err("Invalid move index"))?; - let promo = promo_slice - .get(abs_idx) - .copied() - .ok_or_else(|| pyo3::exceptions::PyIndexError::new_err("Invalid move index"))?; - - let files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; - let ranks = ['1', '2', '3', '4', '5', '6', '7', '8']; - - let mut uci = format!( - "{}{}{}{}", - files[(from_sq % 8) as usize], - ranks[(from_sq / 8) as usize], - files[(to_sq % 8) as usize], - ranks[(to_sq / 8) as usize] - ); - - if promo >= 0 { - let promo_chars = ['_', '_', 'n', 'b', 'r', 'q']; // 2=N, 3=B, 4=R, 5=Q - if (promo as usize) < promo_chars.len() { - uci.push(promo_chars[promo as usize]); - } - } + let from_sq = from_arr.readonly().as_slice()?[abs_idx]; + let to_sq = to_arr.readonly().as_slice()?[abs_idx]; + let promo = promo_arr.readonly().as_slice()?[abs_idx]; - Ok(uci) + Ok(format_uci(from_sq, to_sq, promo)) } - /// Get all moves as UCI strings. + /// Get all moves as UCI strings (e.g. ["e2e4", "e7e5", "g1f3"]). fn moves_uci(&self, py: Python<'_>) -> PyResult> { + let borrowed = self.data.borrow(py); + let chunk = &borrowed.chunks[self.chunk_idx]; + + let from_arr = chunk.from_squares.bind(py); + let from_arr: &Bound<'_, PyArray1> = from_arr.cast()?; + let to_arr = chunk.to_squares.bind(py); + let to_arr: &Bound<'_, PyArray1> = to_arr.cast()?; + let promo_arr = chunk.promotions.bind(py); + let promo_arr: &Bound<'_, PyArray1> = promo_arr.cast()?; + + let from_slice = from_arr.readonly(); + let from_slice = from_slice.as_slice()?; + let to_slice = to_arr.readonly(); + let to_slice = to_slice.as_slice()?; + let promo_slice = promo_arr.readonly(); + let promo_slice = promo_slice.as_slice()?; + let n_moves = self.move_end - self.move_start; let mut result = Vec::with_capacity(n_moves); - for i in 0..n_moves { - result.push(self.move_uci(py, i)?); + for i in self.move_start..self.move_end { + result.push(format_uci(from_slice[i], to_slice[i], promo_slice[i])); } Ok(result) } diff --git a/src/test.py b/src/test.py index 59de284..7090ef9 100644 --- a/src/test.py +++ b/src/test.py @@ -763,6 +763,187 @@ def test_parse_games_from_strings_with_status(self): self.assertEqual(len(moves1), 14) self.assertEqual(moves1[0], "e2e4") + def test_multithreaded_correctness(self): + """Test that multithreaded parsing produces correct results across chunk boundaries.""" + # Generate enough games to force multiple chunks with 4 threads + pgns = [] + for i in range(40): + if i % 3 == 0: + pgns.append("1. e4 e5 2. Nf3 Nc6 1-0") + elif i % 3 == 1: + pgns.append("1. d4 d5 0-1") + else: + pgns.append("1. c4 e5 2. Nc3 1/2-1/2") + + # Parse single-threaded as reference + result_1t = rust_pgn_reader_python_binding.parse_games_from_strings( + pgns, num_threads=1 + ) + + # Parse multi-threaded + result_4t = rust_pgn_reader_python_binding.parse_games_from_strings( + pgns, num_threads=4 + ) + + # Same totals + self.assertEqual(result_1t.num_games, result_4t.num_games) + self.assertEqual(result_1t.num_moves, result_4t.num_moves) + self.assertEqual(result_1t.num_positions, result_4t.num_positions) + self.assertEqual(result_4t.num_games, 40) + + # Multiple chunks were actually created + self.assertGreater(result_4t.num_chunks, 1) + + # Every game matches: moves, outcome, validity + for i in range(40): + g1 = result_1t[i] + g4 = result_4t[i] + self.assertEqual(g1.moves_uci(), g4.moves_uci(), f"Game {i} moves differ") + self.assertEqual(g1.outcome, g4.outcome, f"Game {i} outcome differs") + self.assertEqual(g1.is_valid, g4.is_valid, f"Game {i} validity differs") + + # position_to_game works across chunk boundaries + all_pos = np.arange(result_4t.num_positions) + game_ids = result_4t.position_to_game(all_pos) + game_ids_1t = result_1t.position_to_game(all_pos) + np.testing.assert_array_equal(game_ids, game_ids_1t) + + # move_to_game works across chunk boundaries + all_moves = np.arange(result_4t.num_moves) + move_game_ids = result_4t.move_to_game(all_moves) + move_game_ids_1t = result_1t.move_to_game(all_moves) + np.testing.assert_array_equal(move_game_ids, move_game_ids_1t) + + def test_empty_inputs(self): + """Test empty inputs return valid empty results.""" + # parse_games_from_strings with empty list + result = rust_pgn_reader_python_binding.parse_games_from_strings([]) + self.assertEqual(result.num_games, 0) + self.assertEqual(result.num_moves, 0) + self.assertEqual(result.num_positions, 0) + self.assertEqual(len(result), 0) + + # parse_games with empty chunked array + chunked = pa.chunked_array([pa.array([], type=pa.string())]) + result2 = rust_pgn_reader_python_binding.parse_games(chunked) + self.assertEqual(result2.num_games, 0) + self.assertEqual(len(result2), 0) + + def test_chunk_view_getters(self): + """Test PyChunkView exposes correct array shapes and values.""" + pgns = ["1. e4 e5 1-0", "1. d4 d5 2. c4 0-1"] + chunked = pa.chunked_array([pa.array(pgns)]) + result = rust_pgn_reader_python_binding.parse_games(chunked, num_threads=1) + + self.assertEqual(result.num_chunks, 1) + chunk = result.chunks[0] + + # Scalar getters + self.assertEqual(chunk.num_games, 2) + self.assertEqual(chunk.num_moves, 5) + self.assertEqual(chunk.num_positions, 7) + + # Array shapes + self.assertEqual(chunk.boards.shape, (7, 8, 8)) + self.assertEqual(chunk.castling.shape, (7, 4)) + self.assertEqual(chunk.en_passant.shape, (7,)) + self.assertEqual(chunk.halfmove_clock.shape, (7,)) + self.assertEqual(chunk.turn.shape, (7,)) + self.assertEqual(chunk.from_squares.shape, (5,)) + self.assertEqual(chunk.to_squares.shape, (5,)) + self.assertEqual(chunk.promotions.shape, (5,)) + self.assertEqual(chunk.clocks.shape, (5,)) + self.assertEqual(chunk.evals.shape, (5,)) + self.assertEqual(chunk.is_checkmate.shape, (2,)) + self.assertEqual(chunk.is_stalemate.shape, (2,)) + self.assertEqual(chunk.is_insufficient.shape, (2, 2)) + self.assertEqual(chunk.legal_move_count.shape, (2,)) + self.assertEqual(chunk.valid.shape, (2,)) + + # CSR offset shapes + self.assertEqual(chunk.move_offsets.shape, (3,)) # 2 games + 1 + self.assertEqual(chunk.position_offsets.shape, (3,)) + + # Vec getters + self.assertEqual(len(chunk.headers), 2) + self.assertEqual(len(chunk.outcome), 2) + self.assertEqual(chunk.outcome[0], "White") + self.assertEqual(chunk.outcome[1], "Black") + + # Chunk values match per-game views + game0 = result[0] + game1 = result[1] + np.testing.assert_array_equal(game0.boards, chunk.boards[:3]) + np.testing.assert_array_equal(game1.boards, chunk.boards[3:]) + + def test_legal_moves_from_python(self): + """Test legal_moves property returns correct moves for known positions.""" + pgn = "1. e4 1-0" + result = rust_pgn_reader_python_binding.parse_game(pgn, store_legal_moves=True) + game = result[0] + + legal = game.legal_moves + # 2 positions: initial + after e4 + self.assertEqual(len(legal), 2) + + # Initial position: 20 legal moves + self.assertEqual(len(legal[0]), 20) + + # After e4: black has 20 legal moves + self.assertEqual(len(legal[1]), 20) + + # Verify e2e4 is among initial legal moves (from=12, to=28) + initial_from_to = [(m[0], m[1]) for m in legal[0]] + self.assertIn((12, 28), initial_from_to) # e2e4 + + # Verify d7d5 is among black's legal moves (from=51, to=35) + black_from_to = [(m[0], m[1]) for m in legal[1]] + self.assertIn((51, 35), black_from_to) # d7d5 + + def test_parse_error_surfaced(self): + """Test that parse errors are stored and accessible.""" + pgns = [ + "1. e4 e5 1-0", # Valid + "1. e4 Qxd7 1-0", # Invalid move + ] + result = rust_pgn_reader_python_binding.parse_games_from_strings(pgns) + + # Valid game: no error + self.assertTrue(result[0].is_valid) + self.assertIsNone(result[0].parse_error) + + # Invalid game: error message stored + self.assertFalse(result[1].is_valid) + self.assertIsNotNone(result[1].parse_error) + self.assertIn("illegal move", result[1].parse_error) + + def test_parse_error_invalid_fen(self): + """Test that invalid FEN produces a parse error.""" + pgn = '[FEN "invalid fen string"]\n\n1. e4 e5 1-0' + result = rust_pgn_reader_python_binding.parse_game(pgn) + + self.assertFalse(result[0].is_valid) + self.assertIsNotNone(result[0].parse_error) + self.assertIn("FEN", result[0].parse_error) + + def test_repr(self): + """Test __repr__ on ParsedGames, PyGameView, PyChunkView.""" + pgns = ["1. e4 e5 1-0"] + result = rust_pgn_reader_python_binding.parse_games_from_strings(pgns) + + # ParsedGames repr + r = repr(result) + self.assertIn("ParsedGames", r) + self.assertIn("1 games", r) + + # PyGameView repr + game_repr = repr(result[0]) + self.assertIn("PyGameView", game_repr) + + # PyChunkView repr + chunk_repr = repr(result.chunks[0]) + self.assertIn("PyChunkView", chunk_repr) + if __name__ == "__main__": unittest.main() diff --git a/src/visitor.rs b/src/visitor.rs index b78fa0a..523b31c 100644 --- a/src/visitor.rs +++ b/src/visitor.rs @@ -20,6 +20,19 @@ pub struct ParseConfig { pub store_legal_moves: bool, } +/// Compute CSR-style prefix-sum offsets from a slice of counts. +/// Returns a Vec of length `counts.len() + 1`, starting with 0. +fn prefix_sum(counts: &[u32]) -> Vec { + let mut offsets = Vec::with_capacity(counts.len() + 1); + let mut acc: u32 = 0; + offsets.push(0); + for &count in counts { + acc += count; + offsets.push(acc); + } + offsets +} + /// Accumulated buffers for multiple parsed games. /// /// This struct holds all data in a struct-of-arrays layout, optimized for: @@ -52,6 +65,7 @@ pub struct Buffers { pub valid: Vec, pub headers: Vec>, pub outcome: Vec>, // "White", "Black", "Draw", "Unknown", or None + pub parse_errors: Vec>, // Per-game: None if valid, Some(msg) if not // Optional: raw text comments (per-move), only populated when store_comments=true pub comments: Vec>, @@ -104,6 +118,7 @@ impl Buffers { valid: Vec::with_capacity(estimated_games), headers: Vec::with_capacity(estimated_games), outcome: Vec::with_capacity(estimated_games), + parse_errors: Vec::with_capacity(estimated_games), // Optional comments comments: if config.store_comments { @@ -152,34 +167,19 @@ impl Buffers { self.boards.len() / 64 } - /// Compute CSR-style offsets from counts. + /// Compute CSR-style offsets from move counts. pub fn compute_move_offsets(&self) -> Vec { - let mut offsets = Vec::with_capacity(self.move_counts.len() + 1); - offsets.push(0); - for &count in &self.move_counts { - offsets.push(offsets.last().unwrap() + count); - } - offsets + prefix_sum(&self.move_counts) } /// Compute CSR-style offsets from position counts. pub fn compute_position_offsets(&self) -> Vec { - let mut offsets = Vec::with_capacity(self.position_counts.len() + 1); - offsets.push(0); - for &count in &self.position_counts { - offsets.push(offsets.last().unwrap() + count); - } - offsets + prefix_sum(&self.position_counts) } /// Compute CSR-style offsets from legal move counts (per position). pub fn compute_legal_move_offsets(&self) -> Vec { - let mut offsets = Vec::with_capacity(self.legal_move_counts.len() + 1); - offsets.push(0); - for &count in &self.legal_move_counts { - offsets.push(offsets.last().unwrap() + count); - } - offsets + prefix_sum(&self.legal_move_counts) } /// Total number of legal moves stored across all positions. @@ -199,6 +199,7 @@ pub struct GameVisitor<'a> { valid_moves: bool, current_headers: Vec<(String, String)>, current_outcome: Option, + current_error: Option, // Track counts for current game current_move_count: u32, current_position_count: u32, @@ -213,6 +214,7 @@ impl<'a> GameVisitor<'a> { valid_moves: true, current_headers: Vec::with_capacity(10), current_outcome: None, + current_error: None, current_move_count: 0, current_position_count: 0, } @@ -293,6 +295,12 @@ impl<'a> GameVisitor<'a> { .push(self.pos.legal_moves().len() as u16); } + /// Record a parse error for the current game. + fn set_error(&mut self, msg: String) { + self.valid_moves = false; + self.current_error = Some(msg); + } + /// Finalize current game - record per-game data. fn finalize_game(&mut self) { self.buffers.move_counts.push(self.current_move_count); @@ -301,6 +309,7 @@ impl<'a> GameVisitor<'a> { .push(self.current_position_count); self.buffers.valid.push(self.valid_moves); self.buffers.outcome.push(self.current_outcome.take()); + self.buffers.parse_errors.push(self.current_error.take()); // Convert headers to HashMap let header_map: HashMap = self.current_headers.drain(..).collect(); @@ -334,6 +343,7 @@ impl Visitor for GameVisitor<'_> { self.current_headers = tags; self.valid_moves = true; self.current_outcome = None; + self.current_error = None; self.current_move_count = 0; self.current_position_count = 0; @@ -364,15 +374,13 @@ impl Visitor for GameVisitor<'_> { Ok(fen) => match fen.into_position(castling_mode) { Ok(pos) => self.pos = pos, Err(e) => { - eprintln!("invalid FEN position: {}", e); + self.set_error(format!("invalid FEN position: {}", e)); self.pos = Chess::default(); - self.valid_moves = false; } }, Err(e) => { - eprintln!("failed to parse FEN: {}", e); + self.set_error(format!("failed to parse FEN: {}", e)); self.pos = Chess::default(); - self.valid_moves = false; } } } else { @@ -407,17 +415,12 @@ impl Visitor for GameVisitor<'_> { self.push_move(from as u8, to as u8, promotion.map(|p| p as u8)); } _ => { - eprintln!( - "Unexpected UCI move type: {:?}. Game moves might be invalid.", - uci_move_obj - ); - self.valid_moves = false; + self.set_error(format!("unexpected UCI move type: {:?}", uci_move_obj)); } } } Err(err) => { - eprintln!("error in game: {} {}", err, san_plus); - self.valid_moves = false; + self.set_error(format!("illegal move: {} {}", err, san_plus)); } } }