use car_ir::ActionProposal;
use car_verify::cwm::{
score_predictions, simulate_with_model, GatedEffectModel, GatedPrediction, State, Transition,
};
use serde_json::Value;
use std::collections::HashMap;
pub fn score(transitions_json: &str, predictions_json: &str) -> Result<String, String> {
let transitions: Vec<Transition> = crate::from_json("transitions", transitions_json)?;
let raw: Vec<Value> = crate::from_json("predictions", predictions_json)?;
let predictions: Vec<Result<State, String>> = raw
.into_iter()
.map(|v| match v {
Value::Object(ref m) if m.contains_key("error") => Err(m
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("error")
.to_string()),
Value::Object(m) => {
Ok(m.into_iter().collect::<HashMap<String, Value>>())
}
other => Err(format!("prediction is not an object: {other}")),
})
.collect();
let report = score_predictions(&transitions, &predictions)?;
crate::to_json(&report)
}
pub fn transitions_from_events(
events_jsonl: &str,
initial_state_json: Option<&str>,
actions_json: Option<&str>,
) -> Result<String, String> {
use car_eventlog::{Event, EventKind};
let mut state: State =
crate::from_json_opt("initial_state", initial_state_json)?.unwrap_or_default();
let actions: Option<HashMap<String, Value>> = crate::from_json_opt("actions", actions_json)?;
let mut out: Vec<Transition> = Vec::new();
for line in events_jsonl.lines().filter(|l| !l.trim().is_empty()) {
let ev: Event = match serde_json::from_str(line) {
Ok(e) => e,
Err(_) => continue,
};
if ev.kind != EventKind::StateChanged {
continue;
}
let changes = match ev.data.get("changes").and_then(|c| c.as_object()) {
Some(c) => c,
None => continue,
};
let before = state.clone();
for (k, v) in changes {
state.insert(k.clone(), v.clone());
}
let action = ev
.action_id
.as_ref()
.and_then(|id| actions.as_ref().and_then(|m| m.get(id)).cloned())
.or_else(|| ev.action_id.as_ref().map(|id| serde_json::json!({ "id": id })))
.unwrap_or(Value::Null);
out.push(Transition {
state_before: before,
action,
state_after: state.clone(),
});
}
crate::to_json(&out)
}
pub fn simulate_with_predictions(
proposal_json: &str,
initial_state_json: Option<&str>,
predictions_json: &str,
min_accuracy: f64,
) -> Result<String, String> {
let proposal: ActionProposal = crate::from_json("proposal", proposal_json)?;
let initial_state: Option<State> = crate::from_json_opt("initial_state", initial_state_json)?;
let predictions: HashMap<String, GatedPrediction> =
crate::from_json("predictions", predictions_json)?;
let model = GatedEffectModel {
predictions,
min_accuracy,
};
let state = simulate_with_model(&proposal, initial_state.as_ref(), &model);
crate::to_json(&state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn score_roundtrip_perfect() {
let transitions = r#"[
{"state_before":{"count":0},"action":{"inc":1},"state_after":{"count":1}},
{"state_before":{"count":1},"action":{"inc":2},"state_after":{"count":3}}
]"#;
let predictions = r#"[{"count":1},{"count":3}]"#;
let out = score(transitions, predictions).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["total"], 2);
assert_eq!(v["correct"], 2);
assert_eq!(v["accuracy"], 1.0);
}
#[test]
fn score_reports_error_predictions() {
let transitions =
r#"[{"state_before":{},"action":{},"state_after":{"x":1}}]"#;
let predictions = r#"[{"error":"NameError: boom"}]"#;
let out = score(transitions, predictions).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["errored"], 1);
assert_eq!(v["failures"][0]["error"], "NameError: boom");
}
#[test]
fn score_length_mismatch_errs() {
let transitions =
r#"[{"state_before":{},"action":{},"state_after":{"x":1}}]"#;
assert!(score(transitions, "[]").is_err());
}
#[test]
fn transitions_from_events_bridge() {
let jsonl = [
r#"{"kind":"state_changed","action_id":"a1","proposal_id":"p","data":{"changes":{"count":1}},"timestamp":"2026-06-27T00:00:00Z"}"#,
r#"{"kind":"action_succeeded","action_id":"a1","proposal_id":"p","data":{},"timestamp":"2026-06-27T00:00:01Z"}"#,
r#"{"kind":"state_changed","action_id":"a2","proposal_id":"p","data":{"changes":{"count":3}},"timestamp":"2026-06-27T00:00:02Z"}"#,
]
.join("\n");
let actions = r#"{"a1":{"tool":"inc","by":1}}"#;
let out =
transitions_from_events(&jsonl, Some(r#"{"count":0}"#), Some(actions)).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v.as_array().unwrap().len(), 2);
assert_eq!(v[0]["state_before"]["count"], 0);
assert_eq!(v[0]["state_after"]["count"], 1);
assert_eq!(v[0]["action"]["tool"], "inc");
assert_eq!(v[1]["action"]["id"], "a2");
let preds = r#"[{"count":1},{"count":3}]"#;
let report = score(&out, preds).unwrap();
let rv: Value = serde_json::from_str(&report).unwrap();
assert_eq!(rv["accuracy"], 1.0);
}
#[test]
fn simulate_with_predictions_gate() {
let proposal = r#"{"actions":[
{"type":"tool_call","id":"a1","tool":"t","expected_effects":{"x":1}}
]}"#;
let preds = r#"{"a1":{"effects":{"x":42},"accuracy":0.99}}"#;
let out = simulate_with_predictions(proposal, None, preds, 0.9).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["x"], 42);
let low = r#"{"a1":{"effects":{"x":42},"accuracy":0.5}}"#;
let out2 = simulate_with_predictions(proposal, None, low, 0.9).unwrap();
let v2: Value = serde_json::from_str(&out2).unwrap();
assert_eq!(v2["x"], 1);
}
}