~ntietz/isabella-db

ad602c0403fa1088c74f6e6c5b28ff874aae5286 — Nicole Tietz-Sokolskaya 1 year, 2 months ago 56cbc75
Create opening tree.

This creates a basic opening tree UI which will show the available moves
from a given position, along with their result stats (percents
win/loss/draw).

This entailed some related changes. Mostly, these changes were utilities
in the SparseBitmap.

This is moderately slow right now. With a reasonably sized dataset (3.8M
games) on my server, it takes ~150 ms to generate the moves for an
early-game opening position. Future work will improve this or track down
the cause.

Implements: https://todo.sr.ht/~ntietz/isabella-db/7
M bitmap/src/sparse.rs => bitmap/src/sparse.rs +10 -0
@@ 80,6 80,16 @@ impl SparseBitmap {
        self.size
    }

    pub fn len(&self) -> usize {
        self.runs
            .iter()
            .fold(0, |acc, run| acc + (run.length as usize))
    }

    pub fn is_empty(&self) -> bool {
        self.runs.is_empty()
    }

    fn next_settable_index(&self) -> u32 {
        match self.runs.last() {
            Some(run) => run.start + run.length,

M isabella/src/bin/web.rs => isabella/src/bin/web.rs +58 -8
@@ 1,7 1,22 @@
use actix_web::{middleware::Logger, App, HttpServer};
use isabella_db::web::routes::add_routes;
use std::io::Read;
use std::{fs::File, sync::Arc};

use actix_web::{middleware::Logger, web, App, HttpServer};
use clap::Parser;
use isabella_db::web::context::Context;
use isabella_db::{
    index::{GameResultIndex, PositionIndex},
    web::routes::add_routes,
};
use serde::de::DeserializeOwned;
use tracing_subscriber::{fmt::format::FmtSpan, EnvFilter};

#[derive(Parser, Debug)]
struct Args {
    #[arg(short, long)]
    gamedb_filename: String,
}

#[actix_web::main]
pub async fn main() -> std::io::Result<()> {
    tracing_subscriber::fmt()


@@ 9,14 24,49 @@ pub async fn main() -> std::io::Result<()> {
        .with_span_events(FmtSpan::ACTIVE)
        .init();

    let args = Args::parse();

    tracing::debug!(args=?args, "Parsed args");

    let positions: PositionIndex =
        load_from_disk(&format!("{}.position.idx", args.gamedb_filename));
    tracing::debug!("Loaded position index");

    let results: GameResultIndex = load_from_disk(&format!("{}.result.idx", args.gamedb_filename));
    tracing::debug!("Loaded game result index");

    let context = Arc::new(Context { positions, results });

    tracing::info!("Starting server");

    // TODO: request logging
    //
    HttpServer::new(|| App::new().configure(add_routes).wrap(Logger::default()))
        .bind(("0.0.0.0", 11311))?
        .run()
        .await?;
    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(context.clone()))
            .configure(add_routes)
            .wrap(Logger::default())
    })
    .bind(("0.0.0.0", 11311))?
    .run()
    .await?;

    Ok(())
}

/// Retrieves a serialized datastructure from the disk and returns it.
///
/// Will panic if the file cannot be opened, the content cannot be deserialized,
/// or for any other failure. This is a convenience function to be used for
/// initialization of the web server, where we want to abort if anything cannot
/// be loaded.
fn load_from_disk<T>(filename: &str) -> T
where
    T: DeserializeOwned,
{
    let mut file: File = File::open(filename).expect("should open the file");
    let mut buf: Vec<u8> = Vec::new();

    file.read_to_end(&mut buf).expect("should read");
    tracing::trace!(len = buf.len(), "Read file into buffer");

    bincode::deserialize(&buf).expect("deserializing should work")
}

A isabella/src/web/context.rs => isabella/src/web/context.rs +6 -0
@@ 0,0 1,6 @@
use crate::index::{GameResultIndex, PositionIndex};

pub struct Context {
    pub positions: PositionIndex,
    pub results: GameResultIndex,
}

M isabella/src/web/mod.rs => isabella/src/web/mod.rs +1 -0
@@ 1,3 1,4 @@
pub mod context;
pub mod routes;
pub mod templates;
pub mod views;

M isabella/src/web/templates.rs => isabella/src/web/templates.rs +51 -6
@@ 1,15 1,18 @@
use askama::Template;
use shakmaty::fen::Fen;
use shakmaty::san::SanPlus;
use shakmaty::{Chess, Color, Piece, Position, Role, Square};
use shakmaty::{zobrist::ZobristHash, Chess, Color, Piece, Position, Role, Square};

use super::context::Context;

#[derive(Template)]
#[template(path = "home.html")]
pub struct HomeTemplate;

#[derive(Default, Template)]
#[derive(Template)]
#[template(path = "board.html")]
pub struct BoardTemplate {
pub struct BoardTemplate<'a> {
    pub context: &'a Context,
    pub board: Option<Chess>,
    pub error: Option<&'static str>,
    pub to_move: String,


@@ 21,24 24,51 @@ pub struct AvailableMove {

    /// the url for the following board position
    pub url: String,

    pub stats: Option<GameResultStats>,
}

/// Contains the result rates (win, loss, or draw) out of 100, as an integer.
#[derive(Debug)]
pub struct GameResultStats {
    pub white_wins: f32,
    pub black_wins: f32,
    pub drawn: f32,
}

impl GameResultStats {
    pub fn from_counts(
        white_count: usize,
        black_count: usize,
        draw_count: usize,
    ) -> GameResultStats {
        let total = (white_count + black_count + draw_count) as f32;

        GameResultStats {
            white_wins: (white_count as f32 / total) * 100.0,
            black_wins: (black_count as f32 / total) * 100.0,
            drawn: (draw_count as f32 / total) * 100.0,
        }
    }
}

const INVALID_FEN: &str = "Invalid FEN string";

impl BoardTemplate {
    pub fn new() -> BoardTemplate {
impl<'a> BoardTemplate<'a> {
    pub fn new(context: &'a Context) -> BoardTemplate<'a> {
        let default_board = Chess::new();
        let to_move = default_board.turn().to_string();
        let board = Some(default_board);
        let error = None;
        BoardTemplate {
            context,
            board,
            error,
            to_move,
        }
    }

    pub fn from_fen(fen: &[u8]) -> BoardTemplate {
    pub fn from_fen(context: &'a Context, fen: &[u8]) -> BoardTemplate<'a> {
        let mut error = None;
        let mut board = None;
        let mut to_move: String = "".into();


@@ 58,6 88,7 @@ impl BoardTemplate {
        }

        BoardTemplate {
            context,
            board,
            error,
            to_move,


@@ 72,12 103,26 @@ impl BoardTemplate {
                let mut next_board = board.clone();

                let san = SanPlus::from_move_and_play_unchecked(&mut next_board, &m);
                let hash: u64 = next_board.zobrist_hash();
                let fen =
                    Fen::from_position(next_board, shakmaty::EnPassantMode::Legal).to_string();

                let stats = self.context.positions.get(&hash).map(|positions_bm| {
                    let white_win_bm = positions_bm & &self.context.results.white_won;
                    let black_win_bm = positions_bm & &self.context.results.black_won;
                    let draw_bm = positions_bm & &self.context.results.drawn;

                    GameResultStats::from_counts(
                        white_win_bm.len(),
                        black_win_bm.len(),
                        draw_bm.len(),
                    )
                });

                available_moves.push(AvailableMove {
                    san: san.to_string(),
                    url: format!("/position/?fen={fen}"),
                    stats,
                })
            }
        }

M isabella/src/web/views.rs => isabella/src/web/views.rs +9 -3
@@ 1,8 1,11 @@
use std::sync::Arc;

use actix_web::{route, web, HttpResponse, Responder};
use askama::Template;
use serde::Deserialize;

use super::templates::{BoardTemplate, HomeTemplate};
use crate::web::context::Context;

#[route("/", method = "GET")]
pub async fn home() -> impl Responder {


@@ 16,12 19,15 @@ pub struct FenQuery {
}

#[route("/position/", method = "GET")]
pub async fn position(fen: Option<web::Query<FenQuery>>) -> impl Responder {
pub async fn position(
    fen: Option<web::Query<FenQuery>>,
    context: web::Data<Arc<Context>>,
) -> impl Responder {
    tracing::info!(fen=?fen, "position query-string");

    let template = match fen {
        Some(fen) => BoardTemplate::from_fen(fen.0.fen.as_bytes()),
        None => BoardTemplate::new(),
        Some(fen) => BoardTemplate::from_fen(context.get_ref(), fen.0.fen.as_bytes()),
        None => BoardTemplate::new(context.get_ref()),
    };

    let s = template.render().expect("BoardTemplate should render");

M isabella/templates/board.html => isabella/templates/board.html +6 -0
@@ 32,6 32,12 @@
                {% for m in Self::available_moves(self) %}
                <p>
                    <a href="{{ m.url }}">{{ m.san }}</a>
                    {% match m.stats %}
                    {% when Some with (stats) %}
                    white: {{ stats.white_wins }}, black: {{ stats.black_wins }}, draw: {{ stats.drawn }}.
                    {% when None %}
                    It's a novelty!
                    {% endmatch %}
                </p>
                {% endfor %}
            </div>