use crate::error::{Error, Result};
use crate::model::{BuildConfig, PriorAction, PriorBook, PriorEntry};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
type Entries = HashMap<String, Vec<PriorAction>>;
fn parse_entry_line(line: &str, line_no: usize) -> Result<PriorEntry> {
serde_json::from_str(line).map_err(|source| Error::Json {
line: line_no,
source,
})
}
#[derive(Serialize, Deserialize)]
struct BookHeader {
build_config_fingerprint: u64,
}
fn load_entries(reader: impl Read) -> Result<(Entries, Option<u64>)> {
let mut entries: Entries = HashMap::new();
let mut fingerprint = None;
let mut lines = BufReader::new(reader).lines().enumerate();
if let Some((index, line)) = lines.next() {
let line = line?;
if !line.trim().is_empty() {
match serde_json::from_str::<BookHeader>(&line) {
Ok(header) => fingerprint = Some(header.build_config_fingerprint),
Err(_) => {
let entry = parse_entry_line(&line, index + 1)?;
entries.insert(entry.state, entry.actions);
}
}
}
}
for (index, line) in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
let entry = parse_entry_line(&line, index + 1)?;
entries.insert(entry.state, entry.actions);
}
Ok((entries, fingerprint))
}
pub fn load_prior_book(reader: impl Read) -> Result<PriorBook> {
let (entries, _fingerprint) = load_entries(reader)?;
Ok(PriorBook { entries })
}
pub fn save_prior_book(book: &PriorBook, mut writer: impl Write) -> Result<()> {
for entry in book.entries_sorted() {
serde_json::to_writer(&mut writer, &entry)
.map_err(|e| Error::Io(std::io::Error::other(e)))?;
writer.write_all(b"\n")?;
}
writer.flush()?;
Ok(())
}
pub fn build_config_fingerprint(config: &BuildConfig) -> u64 {
let canonical = serde_json::to_vec(config).expect("BuildConfig always serializes");
crate::hash::fnv1a(&canonical)
}
pub fn save_prior_book_with_config(
book: &PriorBook,
config: &BuildConfig,
mut writer: impl Write,
) -> Result<()> {
let header = BookHeader {
build_config_fingerprint: build_config_fingerprint(config),
};
serde_json::to_writer(&mut writer, &header).map_err(|e| Error::Io(std::io::Error::other(e)))?;
writer.write_all(b"\n")?;
save_prior_book(book, writer)
}
pub fn load_prior_book_with_config(
reader: impl Read,
expected_config: &BuildConfig,
) -> Result<PriorBook> {
let (entries, fingerprint) = load_entries(reader)?;
if let Some(found) = fingerprint {
let expected = build_config_fingerprint(expected_config);
if found != expected {
return Err(Error::BuildConfigMismatch { expected, found });
}
}
Ok(PriorBook { entries })
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_book() -> PriorBook {
let jsonl = concat!(
r#"{"state":"s","actions":[{"action":"a","count":10,"weighted_count":10.0,"success_rate":0.7,"mean_score":0.6,"prior":0.8,"confidence":0.6}]}"#,
"\n"
);
load_prior_book(jsonl.as_bytes()).unwrap()
}
#[test]
fn loads_prior_jsonl_and_queries_known_state() {
let book = sample_book();
let candidates = book.query("s", None);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].action, "a");
}
#[test]
fn query_unseen_state_returns_empty() {
let book = sample_book();
assert!(book.query("nonexistent", None).is_empty());
}
#[test]
fn query_honors_top_k() {
let jsonl = concat!(
r#"{"state":"s","actions":[{"action":"a","count":1,"weighted_count":1.0,"success_rate":null,"mean_score":null,"prior":0.6,"confidence":0.1},{"action":"b","count":1,"weighted_count":1.0,"success_rate":null,"mean_score":null,"prior":0.4,"confidence":0.1}]}"#,
"\n"
);
let book = load_prior_book(jsonl.as_bytes()).unwrap();
let top1 = book.query("s", Some(1));
assert_eq!(top1.len(), 1);
assert_eq!(top1[0].action, "a");
}
#[test]
fn save_then_load_round_trips_a_prior_book() {
let book = sample_book();
let mut buf: Vec<u8> = Vec::new();
save_prior_book(&book, &mut buf).unwrap();
let reloaded = load_prior_book(buf.as_slice()).unwrap();
assert_eq!(reloaded.query("s", None), book.query("s", None));
}
#[test]
fn build_config_fingerprint_is_deterministic() {
let config = BuildConfig::default();
assert_eq!(
build_config_fingerprint(&config),
build_config_fingerprint(&config)
);
}
#[test]
fn build_config_fingerprint_is_sensitive_to_config_changes() {
let a = BuildConfig::default();
let b = BuildConfig {
smoothing_alpha: a.smoothing_alpha + 1.0,
..a.clone()
};
assert_ne!(build_config_fingerprint(&a), build_config_fingerprint(&b));
}
#[test]
fn load_prior_book_skips_a_config_header_transparently() {
let book = sample_book();
let mut buf: Vec<u8> = Vec::new();
save_prior_book_with_config(&book, &BuildConfig::default(), &mut buf).unwrap();
let reloaded = load_prior_book(buf.as_slice()).unwrap();
assert_eq!(reloaded.query("s", None), book.query("s", None));
}
#[test]
fn load_prior_book_with_config_succeeds_when_config_matches() {
let book = sample_book();
let config = BuildConfig::default();
let mut buf: Vec<u8> = Vec::new();
save_prior_book_with_config(&book, &config, &mut buf).unwrap();
let reloaded = load_prior_book_with_config(buf.as_slice(), &config).unwrap();
assert_eq!(reloaded.query("s", None), book.query("s", None));
}
#[test]
fn load_prior_book_with_config_errors_when_config_differs() {
let book = sample_book();
let saved_with = BuildConfig::default();
let mut buf: Vec<u8> = Vec::new();
save_prior_book_with_config(&book, &saved_with, &mut buf).unwrap();
let expected_now = BuildConfig {
smoothing_alpha: saved_with.smoothing_alpha + 1.0,
..saved_with
};
let err = load_prior_book_with_config(buf.as_slice(), &expected_now).unwrap_err();
assert!(matches!(err, Error::BuildConfigMismatch { .. }));
}
#[test]
fn load_prior_book_with_config_accepts_a_plain_headerless_file() {
let book = sample_book();
let mut buf: Vec<u8> = Vec::new();
save_prior_book(&book, &mut buf).unwrap();
let reloaded =
load_prior_book_with_config(buf.as_slice(), &BuildConfig::default()).unwrap();
assert_eq!(reloaded.query("s", None), book.query("s", None));
}
}