use crate::Result;
use crate::config::Mode;
use crate::features::Features;
use crate::hashchain::{Chained, record_hash};
use crate::verdict::{GateResult, Score, Verdict};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Trace {
pub trace_id: Uuid,
pub prev_hash: String,
pub tenant_id: String,
pub session_id: String,
pub ts: Timestamp,
pub mode: Mode,
pub policy: PolicyRef,
pub request: RequestInfo,
pub attempts: Vec<Attempt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deferred: Vec<DeferredVerdict>,
#[serde(rename = "final")]
pub final_: FinalOutcome,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PolicyRef {
pub id: String,
#[serde(default)]
pub explore: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RequestInfo {
pub api: String,
pub prompt_hash: String,
pub features: Features,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Attempt {
pub rung: u32,
pub model: String,
pub provider: String,
pub in_tokens: u64,
pub out_tokens: u64,
pub cost_usd: f64,
pub latency_ms: u64,
pub gates: Vec<GateResult>,
pub verdict: Verdict,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeferredVerdict {
pub gate_id: String,
pub verdict: Verdict,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<Score>,
pub reported_at: Timestamp,
pub reporter: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServedFrom {
Attempt,
BestAttempt,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FinalOutcome {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub served_rung: Option<u32>,
pub served_from: ServedFrom,
pub total_cost_usd: f64,
pub gate_cost_usd: f64,
pub total_latency_ms: u64,
pub escalations: u32,
pub counterfactual_baseline_usd: f64,
pub savings_usd: f64,
}
impl Chained for Trace {
fn prev_hash(&self) -> &str {
&self.prev_hash
}
}
impl Trace {
pub fn hash(&self) -> Result<String> {
record_hash(self)
}
pub fn recompute_savings(&mut self) -> f64 {
let s = self.final_.counterfactual_baseline_usd - self.final_.total_cost_usd;
self.final_.savings_usd = s;
s
}
}
impl std::fmt::Display for Trace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"trace {} rung {:?} served_from {:?} cost ${:.4} saved ${:.4}",
self.trace_id,
self.final_.served_rung,
self.final_.served_from,
self.final_.total_cost_usd,
self.final_.savings_usd,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::features::{Features, TaskKind};
use crate::hashchain::{GENESIS_HASH, verify_chain};
use crate::verdict::Verdict;
fn sample_trace(prev_hash: &str, id: u128) -> Trace {
let mut t = Trace {
trace_id: Uuid::from_u128(id),
prev_hash: prev_hash.to_owned(),
tenant_id: "acme".into(),
session_id: "agent-run-4417".into(),
ts: "2026-07-08T15:04:05Z".parse().unwrap(),
mode: Mode::Enforce,
policy: PolicyRef {
id: "static@v0".into(),
explore: false,
},
request: RequestInfo {
api: "anthropic.messages".into(),
prompt_hash: "deadbeef".into(),
features: Features::new(TaskKind::CodeEdit),
},
attempts: vec![
Attempt {
rung: 0,
model: "anthropic/claude-haiku-4-5".into(),
provider: "anthropic".into(),
in_tokens: 2000,
out_tokens: 700,
cost_usd: 0.0007,
latency_ms: 900,
gates: vec![GateResult::deterministic("cargo-test", Verdict::Fail, 3100)],
verdict: Verdict::Fail,
},
Attempt {
rung: 1,
model: "anthropic/claude-sonnet-5".into(),
provider: "anthropic".into(),
in_tokens: 2000,
out_tokens: 800,
cost_usd: 0.0121,
latency_ms: 1200,
gates: vec![GateResult::deterministic("cargo-test", Verdict::Pass, 2950)],
verdict: Verdict::Pass,
},
],
deferred: vec![],
final_: FinalOutcome {
served_rung: Some(1),
served_from: ServedFrom::Attempt,
total_cost_usd: 0.0128,
gate_cost_usd: 0.0,
total_latency_ms: 2100,
escalations: 1,
counterfactual_baseline_usd: 0.0630,
savings_usd: 0.0,
},
};
t.recompute_savings();
t
}
#[test]
fn wire_field_names_are_the_contract() {
let t = sample_trace(GENESIS_HASH, 1);
let j = serde_json::to_string(&t).unwrap();
assert!(j.contains("\"prev_hash\":"));
assert!(
j.contains("\"final\":"),
"the outcome key must serialize as `final`"
);
assert!(j.contains("\"served_from\":\"attempt\""));
assert!(j.contains("\"verdict\":\"fail\""));
assert!(j.contains("\"verdict\":\"pass\""));
assert!(j.contains("\"counterfactual_baseline_usd\":"));
}
#[test]
fn savings_is_baseline_minus_total() {
let t = sample_trace(GENESIS_HASH, 1);
assert!((t.final_.savings_usd - (0.0630 - 0.0128)).abs() < 1e-12);
}
#[test]
fn traces_chain_and_verify() {
let t0 = sample_trace(GENESIS_HASH, 1);
let t1 = sample_trace(&t0.hash().unwrap(), 2);
let chain = [t0, t1];
assert!(verify_chain(&chain, GENESIS_HASH).is_ok());
}
#[test]
fn tampering_a_served_trace_is_detectable() {
let t0 = sample_trace(GENESIS_HASH, 1);
let t1 = sample_trace(&t0.hash().unwrap(), 2);
let mut chain = [t0, t1];
chain[0].final_.total_cost_usd = 0.0; assert!(verify_chain(&chain, GENESIS_HASH).is_err());
}
#[test]
fn roundtrips_through_json() {
let t = sample_trace(GENESIS_HASH, 7);
let j = serde_json::to_string(&t).unwrap();
let back: Trace = serde_json::from_str(&j).unwrap();
assert_eq!(t, back);
}
}