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,
}
}
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();
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"));
}
}