jja 0.9.1

swiss army knife for chess file formats
Documentation
//
// jja: swiss army knife for chess file formats
// src/brainlearn.rs: BrainLearn experience file constants and utilities
//
// Copyright (c) 2023 Ali Polatel <alip@chesswob.org>
// Based in part upon BrainLearn's src/learn.* which is
//     Copyright (C) 2004-2023 The Stockfish developers (see AUTHORS file)
//
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{
    fmt::{self, Display, Formatter},
    io::{self, Read, Write},
};

use once_cell::sync::Lazy;
use serde::{
    de::{self, Deserialize, Deserializer, SeqAccess, Visitor},
    ser::{Serialize, SerializeTuple, Serializer},
};
use shakmaty::{uci::Uci, Chess, File, Move, Position, Rank, Role, Square};

use crate::tr;

/// Size of a Brainlearn experience file entry in bytes.
pub const EXPERIENCE_ENTRY_SIZE: usize = std::mem::size_of::<ExperienceEntry>();

/// A static string containing comments about the CSV file format used for Brainlearn experience file entries.
///
/// This string contains detailed information about the file format, UCI moves, and the role of
/// depth, score and performance fields in the experience file entries. It can be used as a
/// comment in a CSV file or displayed to users when editing Brainlearn file experience entries.
pub static BRAINLEARN_EDIT_COMMENT: Lazy<String> = Lazy::new(|| {
    tr!(
        "#
# The file is in CSV (comma-separated-values) format.
# Lines starting with `#' are comments and ignored.
#
# Moves are given in UCI (universal chess interface) format
# which is a variation of a long algebraic format for chess
# moves commonly used by chess engines.
#
# Examples:
#   e2e4, e7e5, e1g1 (white short castling), e7e8q (for promotion)
#
# Depth is the engine depth reached in analyzing the move.
# Score is the score of the move.
# Performance is the performance of the move.
#
# All of depth, score and performance are integers in the range of {}..={}.
#
# Edit the file as you like, the moves you deleted will be removed from the experience file.
# If you delete all the moves the position will be removed from the experience file.
# Exit without saving to abort the action.
",
        i32::MIN,
        i32::MAX
    )
});

/// A struct representing a Brainlearn experience file entry.
#[derive(Copy, Clone, Default, Debug)]
#[repr(C, packed)]
pub struct ExperienceEntry {
    /// A 64-bit Zobrist hash of the position.
    pub key: u64,
    /// A 32-bit representation of the depth.
    pub depth: i32,
    /// A 32-bit representation of the score.
    pub score: i32,
    /// A 32-bit compact representation of the move in UCI format.
    pub mov: i32,
    /// A 32-bit value used for performance.
    pub perf: i32,
}

impl Display for ExperienceEntry {
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
        write!(
            f,
            "{}",
            serde_json::to_string(&self).map_err(|_| std::fmt::Error)?
        )
    }
}

impl Serialize for ExperienceEntry {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut tup = serializer.serialize_tuple(5)?;

        let key = self.key;
        tup.serialize_element(&key)?;

        let depth = self.depth;
        tup.serialize_element(&depth)?;

        let score = self.score;
        tup.serialize_element(&score)?;

        let mov = self.mov;
        tup.serialize_element(&mov)?;

        let perf = self.perf;
        tup.serialize_element(&perf)?;

        tup.end()
    }
}

struct ExperienceEntryVisitor;

impl<'de> Visitor<'de> for ExperienceEntryVisitor {
    type Value = ExperienceEntry;

    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        formatter.write_str("a five-element tuple")
    }

    fn visit_seq<A>(self, mut seq: A) -> Result<ExperienceEntry, A::Error>
    where
        A: SeqAccess<'de>,
    {
        let key = seq
            .next_element()?
            .ok_or_else(|| de::Error::invalid_length(0, &self))?;
        let depth = seq
            .next_element()?
            .ok_or_else(|| de::Error::invalid_length(2, &self))?;
        let score = seq
            .next_element()?
            .ok_or_else(|| de::Error::invalid_length(3, &self))?;
        let mov = seq
            .next_element()?
            .ok_or_else(|| de::Error::invalid_length(1, &self))?;
        let perf = seq
            .next_element()?
            .ok_or_else(|| de::Error::invalid_length(4, &self))?;
        Ok(ExperienceEntry {
            key,
            mov,
            depth,
            score,
            perf,
        })
    }
}

impl<'de> Deserialize<'de> for ExperienceEntry {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_tuple(5, ExperienceEntryVisitor)
    }
}

/// Formats a list of experience file entries as a CSV-like string.
///
/// This function takes a reference to a `Chess` position and a vector of `ExperienceEntry`
/// objects, and returns a formatted string representing the book entries in a CSV-like format.
/// Each line in the output string contains the index, UCI notation of the move, weight, and learn
/// value of a book entry, separated by commas.
///
/// # Arguments
///
/// * `position` - A reference to a `Chess` position.
/// * `entries` - A vector of `BookEntry` objects to format.
///
/// # Returns
///
/// A `String` containing the formatted book entries in a CSV-like format.
pub fn format_exp_entries(position: &Chess, entries: Vec<ExperienceEntry>) -> String {
    let mut ret = String::new();

    ret.push_str("*,uci,depth,score,performance\n");
    let mut i = 1;
    for entry in &entries {
        if let Some(mv) = to_move(position, entry.mov) {
            let uci = Uci::from_standard(&mv);
            let depth = entry.depth;
            let score = entry.score;
            let perf = entry.perf;
            ret.push_str(&format!("{},{},{},{},{}\n", i, uci, depth, score, perf));
        }
        i += 1;
    }

    ret
}

/// Writes a `ExperienceEntry` to a file in a binary format.
///
/// This function takes a mutable reference to a type implementing the `Write` trait and a reference
/// to a `ExperienceEntry` object. It writes the `ExperienceEntry` to the file in a binary format.
///
/// # Arguments
///
/// * `f` - A mutable reference to a type implementing the `Write` trait.
/// * `entry` - A reference to the `ExperienceEntry` object to write to the file.
///
/// # Returns
///
/// A `Result<(), io::Error>` indicating success or failure.
pub fn exp_entry_to_file<W: Write>(mut f: W, entry: &ExperienceEntry) -> io::Result<()> {
    f.write_all(&entry.key.to_le_bytes())?;
    f.write_all(&entry.depth.to_le_bytes())?;
    f.write_all(&entry.score.to_le_bytes())?;
    f.write_all(&entry.mov.to_le_bytes())?;
    f.write_all(&entry.perf.to_le_bytes())?;
    Ok(())
}

/// Reads a `ExperienceEntry` from a file in a binary format.
///
/// This function takes a mutable reference to a type implementing the `Read` trait and reads a
/// `ExperienceEntry` object from the file in a binary format.
///
/// # Arguments
///
/// * `f` - A mutable reference to a type implementing the `Read` trait.
///
/// # Returns
///
/// A `Result<ExperienceEntry, io::Error>` containing the `ExperienceEntry` object read from the file or an
/// error if reading fails.
pub fn exp_entry_from_file<R: Read>(mut f: R) -> io::Result<ExperienceEntry> {
    let mut key_buf = [0; 8];
    let mut depth_buf = [0; 4];
    let mut score_buf = [0; 4];
    let mut mov_buf = [0; 4];
    let mut perf_buf = [0; 4];

    f.read_exact(&mut key_buf)?;
    f.read_exact(&mut depth_buf)?;
    f.read_exact(&mut score_buf)?;
    f.read_exact(&mut mov_buf)?;
    f.read_exact(&mut perf_buf)?;

    Ok(ExperienceEntry {
        key: u64::from_le_bytes(key_buf),
        depth: i32::from_le_bytes(depth_buf),
        score: i32::from_le_bytes(score_buf),
        mov: i32::from_le_bytes(mov_buf),
        perf: i32::from_le_bytes(perf_buf),
    })
}

/// Converts a `Move` object into its compact book representation as a 32-bit integer.
///
/// This function takes a `Move` object. It returns a 32-bit integer
/// representing the compact form of the move.
///
/// The resulting `book_move` is encoded as follows:
/// * Bits 0-2: Destination file
/// * Bits 3-5: Destination rank
/// * Bits 6-8: Source file
/// * Bits 9-11: Source rank
/// * Bits 12-13: Promotion piece type - 2 (from KNIGHT-2 to QUEEN-2)
/// * Bits 14-15: Move type (0 for normal, 1 for promotion, 2 for en passant, 3 for castling)
///
/// # Arguments
///
/// * `mov` - A `Move` object representing the move to be compacted.
///
/// # Returns
///
/// An `i32` representing the compact form of the book move.
pub fn from_move(mov: Move) -> i32 {
    let from = match mov.from() {
        Some(square) => square,
        None => panic!("{}", tr!("Move doesn't have a from field.")),
    };
    let to = mov.to();

    let move_type = match mov {
        Move::Normal { promotion, .. } if promotion.is_some() => 1,
        Move::Normal { .. } => 0,
        Move::EnPassant { .. } => 2,
        Move::Castle { .. } => 3,
        Move::Put { .. } => panic!(
            "{}",
            tr!("Put move type isn't supported for compact book moves.")
        ),
    };

    let promotion = match mov.promotion() {
        None => 0,
        Some(role) => match role {
            Role::Knight => 0,
            Role::Bishop => 1,
            Role::Rook => 2,
            Role::Queen => 3,
            _ => panic!(
                "{}",
                tr!(
                    "Invalid promotion role: {}, please report a bug!",
                    format!("{:?}", role)
                )
            ),
        },
    };

    (to.file() as i32 & 0x7)
        | ((to.rank() as i32 & 0x7) << 3)
        | ((from.file() as i32 & 0x7) << 6)
        | ((from.rank() as i32 & 0x7) << 9)
        | ((promotion & 0x3) << 12)
        | ((move_type & 0x3) << 14)
}

/// Finds the corresponding `Move` object for a given compact book move.
///
/// This function takes a reference to a `Chess` position and a 32-bit integer representing a compact
/// book move. It returns the corresponding `Move` object if the move is legal in the given position.
///
/// The `book_move` is encoded as follows:
/// * Bits 0-2: Destination file
/// * Bits 3-5: Destination rank
/// * Bits 6-8: Source file
/// * Bits 9-11: Source rank
/// * Bits 12-13: Promotion piece type - 2 (from KNIGHT-2 to QUEEN-2)
/// * Bits 14-15: Move type (0 for normal, 1 for promotion, 2 for en passant, 3 for castling)
///
/// # Arguments
///
/// * `position` - A reference to a `Chess` position.
/// * `book_move` - An `i32` representing the compact form of the book move.
///
/// # Returns
///
/// An `Option<Move>` containing the corresponding `Move` object if it is legal in the given position,
/// otherwise `None`.
pub fn to_move(position: &Chess, book_move: i32) -> Option<Move> {
    let to = Square::from_coords(
        File::new((book_move as u32/* >> 0 */) & 0x7),
        Rank::new((book_move as u32 >> 3) & 0x7),
    );
    let from = Square::from_coords(
        File::new((book_move as u32 >> 6) & 0x7),
        Rank::new((book_move as u32 >> 9) & 0x7),
    );

    let movetype = (book_move >> 14) & 0x3;
    let promotion = if movetype != 1 {
        None
    } else {
        match (book_move >> 12) & 0x3 {
            0 => Some(Role::Knight),
            1 => Some(Role::Bishop),
            2 => Some(Role::Rook),
            3 => Some(Role::Queen),
            n => {
                panic!(
                    "{}",
                    tr!("Invalid promotion role: {}, please report a bug!", n)
                );
            }
        }
    };

    let board = position.board();
    let mov = match movetype {
        0 | 1 =>
        /* normal move or promotion. */
        {
            Move::Normal {
                role: board.role_at(from)?,
                from,
                capture: board.role_at(to),
                to,
                promotion,
            }
        }
        2 => Move::EnPassant { from, to },
        3 => Move::Castle {
            king: from,
            rook: to,
        },
        _ => {
            panic!(
                "{}",
                tr!("Invalid move type {}, please report a bug!", movetype)
            );
        }
    };

    Some(mov)
}