sekirei 0.3.67

USI shogi engine binary (Sekirei)
//! Statistical opening book, generated by `sekirei-train --build-book` via
//! `lineprior` (<https://github.com/kent-tokyo/lineprior>). `lineprior` owns
//! the JSONL schema, the count/win-rate smoothing, and the confidence
//! scoring/ranking; this module only adds the one thing a domain-agnostic
//! library can't do itself -- mapping its ranked candidate actions back
//! onto real, currently-legal shogi moves.

use std::io::Cursor;

use lineprior::{PriorBook, PriorBookMetadata};
use sekirei_core::board::Board;
use sekirei_core::mv::Move;
use sekirei_core::sfen::move_from_usi;
use serde::Serialize;
use sha2::{Digest, Sha256};

pub struct Book {
    inner: PriorBook,
    provenance: BookProvenance,
    sha256: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BookProvenance {
    Versioned {
        schema_version: u32,
        producer_version: String,
        build_config: String,
        build_config_fingerprint: u64,
    },
    LegacyFingerprint,
    Headerless,
}

impl Book {
    pub fn load(path: &str) -> Result<Book, String> {
        let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
        let sha256 = format!("{:x}", Sha256::digest(&bytes));
        let loaded = lineprior::load_prior_book_with_metadata(Cursor::new(bytes))
            .map_err(|e| e.to_string())?;
        let provenance = match loaded.metadata {
            Some(PriorBookMetadata::Versioned(metadata)) => BookProvenance::Versioned {
                schema_version: metadata.schema_version,
                producer_version: metadata.producer_version,
                build_config: metadata.build_config.to_string(),
                build_config_fingerprint: metadata.build_config_fingerprint,
            },
            Some(PriorBookMetadata::LegacyFingerprint { .. }) => BookProvenance::LegacyFingerprint,
            Some(_) => return Err("unsupported opening-book metadata variant".to_string()),
            None => BookProvenance::Headerless,
        };
        Ok(Book {
            inner: loaded.book,
            provenance,
            sha256,
        })
    }

    pub fn len(&self) -> usize {
        self.inner.entries.len()
    }

    pub fn provenance(&self) -> &BookProvenance {
        &self.provenance
    }

    pub fn sha256(&self) -> &str {
        &self.sha256
    }

    pub fn build_config_fingerprint(&self) -> Option<u64> {
        match &self.provenance {
            BookProvenance::Versioned {
                build_config_fingerprint,
                ..
            } => Some(*build_config_fingerprint),
            BookProvenance::LegacyFingerprint | BookProvenance::Headerless => None,
        }
    }

    pub fn schema_version(&self) -> Option<u32> {
        match &self.provenance {
            BookProvenance::Versioned { schema_version, .. } => Some(*schema_version),
            BookProvenance::LegacyFingerprint | BookProvenance::Headerless => None,
        }
    }

    pub fn producer_version(&self) -> Option<&str> {
        match &self.provenance {
            BookProvenance::Versioned {
                producer_version, ..
            } => Some(producer_version),
            BookProvenance::LegacyFingerprint | BookProvenance::Headerless => None,
        }
    }

    /// Walks `sfen`'s candidates in lineprior's own ranked (descending
    /// prior) order, returning the first whose confidence clears
    /// `min_confidence` *and* whose USI string still parses to a legal
    /// move against `board`. Skipping past a low-confidence or now-illegal
    /// entry instead of stopping at the first one is what keeps a stale or
    /// noisy book from ever forcing a bad move -- worst case this returns
    /// `None` and the caller falls back to a normal search, exactly the
    /// designed behavior for an unseen state.
    pub fn lookup(&self, sfen: &str, board: &Board, min_confidence: f64) -> Option<Move> {
        self.decision(sfen, board, min_confidence).selected
    }

    pub fn decision(&self, sfen: &str, board: &Board, min_confidence: f64) -> BookDecision {
        let mut selected = None;
        let candidates = self
            .inner
            .query(sfen, None)
            .into_iter()
            .enumerate()
            .map(|(rank, action)| {
                let confidence_pass = action.confidence >= min_confidence;
                let parsed = move_from_usi(&action.action, board).ok();
                let legal = parsed.is_some();
                if selected.is_none() && confidence_pass && legal {
                    selected = parsed;
                }
                BookCandidate {
                    rank: rank + 1,
                    action: action.action,
                    prior: action.prior,
                    confidence: action.confidence,
                    confidence_pass,
                    legal,
                }
            })
            .collect::<Vec<_>>();
        let fallback_reason = if selected.is_some() {
            None
        } else if candidates.is_empty() {
            Some("unseen_state")
        } else if candidates
            .iter()
            .all(|candidate| !candidate.confidence_pass)
        {
            Some("low_confidence")
        } else {
            Some("no_legal_candidate")
        };
        BookDecision {
            selected,
            candidates,
            fallback_reason,
        }
    }
}

#[derive(Debug)]
pub struct BookDecision {
    pub selected: Option<Move>,
    pub candidates: Vec<BookCandidate>,
    pub fallback_reason: Option<&'static str>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BookCandidate {
    pub rank: usize,
    pub action: String,
    pub prior: f64,
    pub confidence: f64,
    pub confidence_pass: bool,
    pub legal: bool,
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::File;
    use std::io::Write;

    fn load_from(contents: &str) -> Book {
        let path = std::env::temp_dir().join(format!(
            "sekirei_book_test_{}_{:?}.jsonl",
            std::process::id(),
            std::thread::current().id()
        ));
        let mut f = File::create(&path).unwrap();
        f.write_all(contents.as_bytes()).unwrap();
        let book = Book::load(path.to_str().unwrap()).expect("load");
        std::fs::remove_file(&path).ok();
        book
    }

    const STARTPOS_SFEN: &str = "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1";

    #[test]
    fn picks_the_highest_ranked_legal_move_above_confidence() {
        let book = load_from(&format!(
            r#"{{"state":{STARTPOS_SFEN:?},"actions":[{{"action":"7g7f","count":120,"weighted_count":120.0,"success_rate":0.55,"mean_score":0.55,"prior":0.6,"confidence":0.9}},{{"action":"2g2f","count":90,"weighted_count":90.0,"success_rate":0.48,"mean_score":0.48,"prior":0.4,"confidence":0.85}}]}}"#
        ));
        assert_eq!(book.len(), 1);
        let board = Board::startpos();
        let mv = book
            .lookup(STARTPOS_SFEN, &board, 0.2)
            .expect("should find a book move");
        assert_eq!(sekirei_core::sfen::move_to_usi(mv), "7g7f");
    }

    #[test]
    fn skips_entries_below_min_confidence() {
        let book = load_from(&format!(
            r#"{{"state":{STARTPOS_SFEN:?},"actions":[{{"action":"7g7f","count":1,"weighted_count":1.0,"success_rate":1.0,"mean_score":1.0,"prior":0.9,"confidence":0.05}},{{"action":"2g2f","count":90,"weighted_count":90.0,"success_rate":0.48,"mean_score":0.48,"prior":0.4,"confidence":0.85}}]}}"#
        ));
        let board = Board::startpos();
        // Top-ranked entry (7g7f) has a high prior from one lucky sample but
        // low confidence -- must be skipped in favor of the well-supported one.
        let mv = book
            .lookup(STARTPOS_SFEN, &board, 0.2)
            .expect("should fall through to 2g2f");
        assert_eq!(sekirei_core::sfen::move_to_usi(mv), "2g2f");
    }

    #[test]
    fn decision_explains_low_confidence_and_illegal_candidates() {
        let book = load_from(&format!(
            r#"{{"state":{STARTPOS_SFEN:?},"actions":[{{"action":"not-a-move","count":10,"weighted_count":10.0,"success_rate":0.5,"mean_score":0.5,"prior":0.7,"confidence":0.9}},{{"action":"7g7f","count":1,"weighted_count":1.0,"success_rate":1.0,"mean_score":1.0,"prior":0.3,"confidence":0.05}}]}}"#
        ));
        let decision = book.decision(STARTPOS_SFEN, &Board::startpos(), 0.2);
        assert!(decision.selected.is_none());
        assert_eq!(decision.fallback_reason, Some("no_legal_candidate"));
        assert!(!decision.candidates[0].legal);
        assert!(decision.candidates[0].confidence_pass);
        assert!(decision.candidates[1].legal);
        assert!(!decision.candidates[1].confidence_pass);
    }

    #[test]
    fn load_records_exact_artifact_sha256() {
        let contents = format!(
            r#"{{"state":{STARTPOS_SFEN:?},"actions":[{{"action":"7g7f","count":10,"weighted_count":10.0,"success_rate":0.5,"mean_score":0.5,"prior":0.5,"confidence":0.5}}]}}"#
        );
        let book = load_from(&contents);
        assert_eq!(
            book.sha256(),
            format!("{:x}", Sha256::digest(contents.as_bytes()))
        );
    }

    #[test]
    fn unseen_state_returns_none() {
        let book = load_from(&format!(
            r#"{{"state":{STARTPOS_SFEN:?},"actions":[{{"action":"7g7f","count":10,"weighted_count":10.0,"success_rate":0.5,"mean_score":0.5,"prior":0.5,"confidence":0.5}}]}}"#
        ));
        let board = Board::startpos();
        assert!(
            book.lookup("some-other-sfen-not-in-book", &board, 0.0)
                .is_none()
        );
    }

    #[test]
    fn unseen_state_has_an_explicit_fallback_reason() {
        let book = load_from("");
        let decision = book.decision(STARTPOS_SFEN, &Board::startpos(), 0.2);
        assert!(decision.selected.is_none());
        assert_eq!(decision.fallback_reason, Some("unseen_state"));
        assert!(decision.candidates.is_empty());
    }

    #[test]
    fn loads_lineprior_schema_v1_book() {
        let book = load_from(&format!(
            concat!(
                r#"{{"prior_book_schema_version":1,"producer_version":"0.12.1","build_config":{{}},"build_config_fingerprint":0}}"#,
                "\n",
                r#"{{"state":{:?},"actions":[{{"action":"7g7f","count":10,"weighted_count":10.0,"success_rate":0.5,"mean_score":0.5,"prior":0.5,"confidence":0.5}}]}}"#
            ),
            STARTPOS_SFEN
        ));
        assert_eq!(book.len(), 1);
        assert_eq!(
            book.provenance(),
            &BookProvenance::Versioned {
                schema_version: 1,
                producer_version: "0.12.1".to_string(),
                build_config: "{}".to_string(),
                build_config_fingerprint: 0,
            }
        );
        assert!(
            book.lookup(STARTPOS_SFEN, &Board::startpos(), 0.0)
                .is_some()
        );
    }

    #[test]
    fn distinguishes_headerless_legacy_books() {
        let book = load_from(&format!(
            r#"{{"state":{STARTPOS_SFEN:?},"actions":[{{"action":"7g7f","count":10,"weighted_count":10.0,"success_rate":0.5,"mean_score":0.5,"prior":0.5,"confidence":0.5}}]}}"#
        ));
        assert_eq!(book.provenance(), &BookProvenance::Headerless);
    }

    #[test]
    fn rejects_unsupported_metadata_schema() {
        let path = std::env::temp_dir().join(format!(
            "sekirei_book_bad_schema_{}_{:?}.jsonl",
            std::process::id(),
            std::thread::current().id()
        ));
        std::fs::write(
            &path,
            r#"{"prior_book_schema_version":99,"producer_version":"future","build_config":{},"build_config_fingerprint":0}"#,
        )
        .unwrap();
        let error = Book::load(path.to_str().unwrap())
            .err()
            .expect("future schema must be rejected");
        std::fs::remove_file(path).ok();
        assert!(error.contains("unsupported prior-book schema version"));
    }
}