use crate::error::{Error, Result, Warning};
use crate::model::{BuildConfig, Observation, Outcome, PriorBook};
use serde::Deserialize;
use std::io::{BufRead, BufReader, Read};
#[derive(Deserialize)]
struct RawObservation {
sequence_id: Option<String>,
step: Option<u32>,
state: Option<String>,
action: Option<String>,
outcome: Option<String>,
score: Option<f64>,
weight: Option<f64>,
tags: Option<Vec<String>>,
observed_at_unix_seconds: Option<i64>,
source: Option<String>,
}
#[derive(Debug, Default)]
pub struct ParseOutcome {
pub observations: Vec<Observation>,
pub warnings: Vec<Warning>,
}
fn build_observation(raw: RawObservation, line: usize) -> Result<Observation> {
let sequence_id = raw.sequence_id.ok_or(Error::MissingField {
line,
field: "sequence_id",
})?;
let step = raw.step.ok_or(Error::MissingField {
line,
field: "step",
})?;
let state = raw.state.ok_or(Error::MissingField {
line,
field: "state",
})?;
let action = raw.action.ok_or(Error::MissingField {
line,
field: "action",
})?;
if state.is_empty() {
return Err(Error::EmptyState { line });
}
if action.is_empty() {
return Err(Error::EmptyAction { line });
}
let outcome = match raw.outcome {
None => Outcome::Unknown,
Some(value) => Outcome::parse(&value).ok_or(Error::UnsupportedOutcome { line, value })?,
};
let score = match raw.score {
None => None,
Some(value) if value.is_nan() => return Err(Error::NanScore { line }),
Some(value) => Some(value),
};
let weight = raw.weight.unwrap_or(1.0);
if weight < 0.0 {
return Err(Error::NegativeWeight {
line,
value: weight,
});
}
Ok(Observation {
sequence_id,
step,
state,
action,
outcome,
score,
weight,
tags: raw.tags.unwrap_or_default(),
observed_at_unix_seconds: raw.observed_at_unix_seconds,
source: raw.source,
})
}
pub(crate) fn parse_line(line: &str, line_no: usize) -> Result<Observation> {
serde_json::from_str::<RawObservation>(line)
.map_err(|source| Error::Json {
line: line_no,
source,
})
.and_then(|raw| build_observation(raw, line_no))
}
pub fn parse_jsonl(reader: impl Read, strict: bool) -> Result<ParseOutcome> {
let mut outcome = ParseOutcome::default();
for (index, line) in BufReader::new(reader).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let line_no = index + 1;
match parse_line(&line, line_no) {
Ok(observation) => outcome.observations.push(observation),
Err(err) if strict => return Err(err),
Err(err) => outcome.warnings.push(Warning {
line: line_no,
message: err.to_string(),
}),
}
}
Ok(outcome)
}
#[derive(Debug)]
pub struct BuildOutput {
pub book: PriorBook,
pub warnings: Vec<Warning>,
pub stats: crate::build::BuildStats,
}
pub fn build_prior_book_from_reader(
reader: impl Read,
strict: bool,
config: &BuildConfig,
) -> Result<BuildOutput> {
let mut acc = crate::build::PriorAccumulator::new(config)?;
let mut warnings = Vec::new();
for (index, line) in BufReader::new(reader).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let line_no = index + 1;
match parse_line(&line, line_no) {
Ok(observation) => acc.observe(&observation),
Err(err) if strict => return Err(err),
Err(err) => warnings.push(Warning {
line: line_no,
message: err.to_string(),
}),
}
}
let (book, stats) = acc.finish_with_stats();
Ok(BuildOutput {
book,
warnings,
stats,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(input: &str, strict: bool) -> Result<ParseOutcome> {
parse_jsonl(input.as_bytes(), strict)
}
#[test]
fn parses_valid_jsonl() {
let input = r#"{"sequence_id":"case-001","step":0,"state":"s","action":"a","outcome":"success","score":0.8,"weight":2.0,"tags":["t"]}"#;
let outcome = parse(input, true).unwrap();
assert_eq!(outcome.observations.len(), 1);
let obs = &outcome.observations[0];
assert_eq!(obs.sequence_id, "case-001");
assert_eq!(obs.state, "s");
assert_eq!(obs.action, "a");
assert_eq!(obs.outcome, Outcome::Success);
assert_eq!(obs.score, Some(0.8));
assert_eq!(obs.weight, 2.0);
assert_eq!(obs.tags, vec!["t".to_string()]);
}
#[test]
fn rejects_malformed_jsonl_in_strict_mode() {
let err = parse("{not json}", true).unwrap_err();
assert!(matches!(err, Error::Json { line: 1, .. }));
}
#[test]
fn defaults_missing_optional_fields() {
let input = r#"{"sequence_id":"c","step":0,"state":"s","action":"a"}"#;
let outcome = parse(input, true).unwrap();
let obs = &outcome.observations[0];
assert_eq!(obs.outcome, Outcome::Unknown);
assert_eq!(obs.score, None);
assert_eq!(obs.weight, 1.0);
assert!(obs.tags.is_empty());
}
#[test]
fn strict_mode_aborts_on_first_invalid_record() {
let input = "{\"sequence_id\":\"c\",\"step\":0,\"state\":\"s\",\"action\":\"a\"}\n{\"state\":\"\",\"action\":\"a\",\"sequence_id\":\"c\",\"step\":1}\n";
let err = parse(input, true).unwrap_err();
assert!(matches!(err, Error::EmptyState { line: 2 }));
}
#[test]
fn non_strict_mode_skips_invalid_records_and_warns() {
let input = "{\"sequence_id\":\"c\",\"step\":0,\"state\":\"s\",\"action\":\"a\"}\n{\"state\":\"\",\"action\":\"a\",\"sequence_id\":\"c\",\"step\":1}\n";
let outcome = parse(input, false).unwrap();
assert_eq!(outcome.observations.len(), 1);
assert_eq!(outcome.warnings.len(), 1);
assert_eq!(outcome.warnings[0].line, 2);
}
#[test]
fn empty_input_yields_no_observations() {
let outcome = parse("", true).unwrap();
assert!(outcome.observations.is_empty());
assert!(outcome.warnings.is_empty());
}
#[test]
fn rejects_negative_weight() {
let input = r#"{"sequence_id":"c","step":0,"state":"s","action":"a","weight":-1.0}"#;
let err = parse(input, true).unwrap_err();
assert!(matches!(err, Error::NegativeWeight { line: 1, .. }));
}
#[test]
fn accepts_zero_weight() {
let input = r#"{"sequence_id":"c","step":0,"state":"s","action":"a","weight":0.0}"#;
let outcome = parse(input, true).unwrap();
assert_eq!(outcome.observations[0].weight, 0.0);
}
#[test]
fn rejects_unsupported_outcome() {
let input = r#"{"sequence_id":"c","step":0,"state":"s","action":"a","outcome":"win"}"#;
let err = parse(input, true).unwrap_err();
assert!(matches!(err, Error::UnsupportedOutcome { line: 1, .. }));
}
#[test]
fn rejects_nan_score_at_the_validation_layer() {
let raw = RawObservation {
sequence_id: Some("c".into()),
step: Some(0),
state: Some("s".into()),
action: Some("a".into()),
outcome: None,
score: Some(f64::NAN),
weight: None,
tags: None,
observed_at_unix_seconds: None,
source: None,
};
let err = build_observation(raw, 7).unwrap_err();
assert!(matches!(err, Error::NanScore { line: 7 }));
}
#[test]
fn allows_duplicate_sequence_ids_across_steps() {
let input = "{\"sequence_id\":\"c\",\"step\":0,\"state\":\"s\",\"action\":\"a\"}\n{\"sequence_id\":\"c\",\"step\":1,\"state\":\"s2\",\"action\":\"a2\"}\n";
let outcome = parse(input, true).unwrap();
assert_eq!(outcome.observations.len(), 2);
assert_eq!(outcome.observations[0].sequence_id, "c");
assert_eq!(outcome.observations[1].sequence_id, "c");
}
#[test]
fn build_prior_book_from_reader_matches_eager_path_on_non_empty_input() {
let jsonl = concat!(
"{\"sequence_id\":\"c1\",\"step\":0,\"state\":\"s\",\"action\":\"a\",\"outcome\":\"success\",\"weight\":1.0,\"tags\":[\"trusted\"]}\n",
"{\"sequence_id\":\"c2\",\"step\":0,\"state\":\"s\",\"action\":\"a\",\"outcome\":\"draw\",\"weight\":1.0,\"tags\":[\"trusted\"]}\n",
"{\"sequence_id\":\"c3\",\"step\":0,\"state\":\"s\",\"action\":\"b\",\"outcome\":\"failure\",\"weight\":2.0}\n",
"{\"sequence_id\":\"c4\",\"step\":99,\"state\":\"s\",\"action\":\"late\",\"outcome\":\"success\",\"weight\":1.0,\"tags\":[\"trusted\"]}\n",
);
let config = BuildConfig {
max_step: Some(10),
draw_value: 0.3,
min_weighted_count: 0.5,
tag_filter: Some(vec!["trusted".to_string()]),
..BuildConfig::default()
};
let eager = {
let parsed = parse(jsonl, true).unwrap();
crate::build::build_prior_book(&parsed.observations, &config).unwrap()
};
let streaming = build_prior_book_from_reader(jsonl.as_bytes(), true, &config).unwrap();
assert!(!streaming.book.entries.is_empty());
assert_eq!(eager.entries_sorted(), streaming.book.entries_sorted());
}
#[test]
fn build_prior_book_from_reader_collects_warnings_in_non_strict_mode() {
let jsonl = "{\"sequence_id\":\"c1\",\"step\":0,\"state\":\"s\",\"action\":\"a\"}\n{\"state\":\"\",\"action\":\"a\",\"sequence_id\":\"c2\",\"step\":1}\n";
let output =
build_prior_book_from_reader(jsonl.as_bytes(), false, &BuildConfig::default()).unwrap();
assert_eq!(output.warnings.len(), 1);
assert_eq!(output.warnings[0].line, 2);
assert_eq!(output.book.entries["s"].len(), 1);
assert_eq!(output.book.entries["s"][0].action, "a");
}
#[test]
fn build_prior_book_from_reader_strict_mode_aborts_on_first_invalid_record() {
let jsonl = "{\"sequence_id\":\"c1\",\"step\":0,\"state\":\"s\",\"action\":\"a\"}\n{\"state\":\"\",\"action\":\"a\",\"sequence_id\":\"c2\",\"step\":1}\n";
let err = build_prior_book_from_reader(jsonl.as_bytes(), true, &BuildConfig::default())
.unwrap_err();
assert!(matches!(err, Error::EmptyState { line: 2 }));
}
#[test]
fn build_prior_book_from_reader_returns_empty_book_when_input_is_empty() {
let output =
build_prior_book_from_reader("".as_bytes(), true, &BuildConfig::default()).unwrap();
assert!(output.book.entries.is_empty());
assert!(output.warnings.is_empty());
}
#[test]
fn build_prior_book_from_reader_returns_empty_book_when_everything_is_filtered_out() {
let jsonl = "{\"sequence_id\":\"c1\",\"step\":99,\"state\":\"s\",\"action\":\"a\"}\n";
let config = BuildConfig {
max_step: Some(1),
..BuildConfig::default()
};
let output = build_prior_book_from_reader(jsonl.as_bytes(), true, &config).unwrap();
assert!(output.book.entries.is_empty());
}
#[test]
fn empty_input_deliberately_diverges_from_the_eager_path() {
assert!(crate::build::build_prior_book(&[], &BuildConfig::default()).is_err());
let output =
build_prior_book_from_reader("".as_bytes(), true, &BuildConfig::default()).unwrap();
assert!(output.book.entries.is_empty());
}
}