1use crate::Result;
13use crate::config::Mode;
14use crate::features::Features;
15use crate::hashchain::{Chained, record_hash};
16use crate::verdict::{GateResult, Score, Verdict};
17use jiff::Timestamp;
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Trace {
24 pub trace_id: Uuid,
26 pub prev_hash: String,
28 pub tenant_id: String,
30 pub session_id: String,
32 pub ts: Timestamp,
34 pub mode: Mode,
36 pub policy: PolicyRef,
38 pub request: RequestInfo,
40 pub attempts: Vec<Attempt>,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub deferred: Vec<DeferredVerdict>,
45 #[serde(rename = "final")]
47 pub final_: FinalOutcome,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct PolicyRef {
53 pub id: String,
55 #[serde(default)]
57 pub explore: bool,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub propensity: Option<f64>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct RequestInfo {
71 pub api: String,
73 pub prompt_hash: String,
75 pub features: Features,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct Attempt {
82 pub rung: u32,
84 pub model: String,
86 pub provider: String,
88 pub in_tokens: u64,
90 pub out_tokens: u64,
92 pub cost_usd: f64,
94 pub latency_ms: u64,
96 pub gates: Vec<GateResult>,
98 pub verdict: Verdict,
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct DeferredVerdict {
105 pub gate_id: String,
107 pub verdict: Verdict,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub score: Option<Score>,
112 pub reported_at: Timestamp,
114 pub reporter: String,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ServedFrom {
122 Attempt,
124 BestAttempt,
126 Error,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct FinalOutcome {
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub served_rung: Option<u32>,
136 pub served_from: ServedFrom,
138 pub total_cost_usd: f64,
140 pub gate_cost_usd: f64,
142 pub total_latency_ms: u64,
144 pub escalations: u32,
146 pub counterfactual_baseline_usd: f64,
148 pub savings_usd: f64,
150}
151
152impl Chained for Trace {
153 fn prev_hash(&self) -> &str {
154 &self.prev_hash
155 }
156}
157
158impl Trace {
159 pub fn hash(&self) -> Result<String> {
165 record_hash(self)
166 }
167
168 pub fn recompute_savings(&mut self) -> f64 {
171 let s = self.final_.counterfactual_baseline_usd - self.final_.total_cost_usd;
172 self.final_.savings_usd = s;
173 s
174 }
175}
176
177impl std::fmt::Display for Trace {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 write!(
180 f,
181 "trace {} rung {:?} served_from {:?} cost ${:.4} saved ${:.4}",
182 self.trace_id,
183 self.final_.served_rung,
184 self.final_.served_from,
185 self.final_.total_cost_usd,
186 self.final_.savings_usd,
187 )
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::features::{Features, TaskKind};
195 use crate::hashchain::{GENESIS_HASH, verify_chain};
196 use crate::verdict::Verdict;
197
198 fn sample_trace(prev_hash: &str, id: u128) -> Trace {
199 let mut t = Trace {
200 trace_id: Uuid::from_u128(id),
201 prev_hash: prev_hash.to_owned(),
202 tenant_id: "acme".into(),
203 session_id: "agent-run-4417".into(),
204 ts: "2026-07-08T15:04:05Z".parse().unwrap(),
205 mode: Mode::Enforce,
206 policy: PolicyRef {
207 id: "static@v0".into(),
208 explore: false,
209 propensity: None,
210 },
211 request: RequestInfo {
212 api: "anthropic.messages".into(),
213 prompt_hash: "deadbeef".into(),
214 features: Features::new(TaskKind::CodeEdit),
215 },
216 attempts: vec![
217 Attempt {
218 rung: 0,
219 model: "anthropic/claude-haiku-4-5".into(),
220 provider: "anthropic".into(),
221 in_tokens: 2000,
222 out_tokens: 700,
223 cost_usd: 0.0007,
224 latency_ms: 900,
225 gates: vec![GateResult::deterministic("cargo-test", Verdict::Fail, 3100)],
226 verdict: Verdict::Fail,
227 },
228 Attempt {
229 rung: 1,
230 model: "anthropic/claude-sonnet-5".into(),
231 provider: "anthropic".into(),
232 in_tokens: 2000,
233 out_tokens: 800,
234 cost_usd: 0.0121,
235 latency_ms: 1200,
236 gates: vec![GateResult::deterministic("cargo-test", Verdict::Pass, 2950)],
237 verdict: Verdict::Pass,
238 },
239 ],
240 deferred: vec![],
241 final_: FinalOutcome {
242 served_rung: Some(1),
243 served_from: ServedFrom::Attempt,
244 total_cost_usd: 0.0128,
245 gate_cost_usd: 0.0,
246 total_latency_ms: 2100,
247 escalations: 1,
248 counterfactual_baseline_usd: 0.0630,
249 savings_usd: 0.0,
250 },
251 };
252 t.recompute_savings();
253 t
254 }
255
256 #[test]
257 fn wire_field_names_are_the_contract() {
258 let t = sample_trace(GENESIS_HASH, 1);
259 let j = serde_json::to_string(&t).unwrap();
260 assert!(j.contains("\"prev_hash\":"));
261 assert!(
262 j.contains("\"final\":"),
263 "the outcome key must serialize as `final`"
264 );
265 assert!(j.contains("\"served_from\":\"attempt\""));
266 assert!(j.contains("\"verdict\":\"fail\""));
267 assert!(j.contains("\"verdict\":\"pass\""));
268 assert!(j.contains("\"counterfactual_baseline_usd\":"));
269 }
270
271 #[test]
272 fn savings_is_baseline_minus_total() {
273 let t = sample_trace(GENESIS_HASH, 1);
274 assert!((t.final_.savings_usd - (0.0630 - 0.0128)).abs() < 1e-12);
275 }
276
277 #[test]
278 fn traces_chain_and_verify() {
279 let t0 = sample_trace(GENESIS_HASH, 1);
280 let t1 = sample_trace(&t0.hash().unwrap(), 2);
281 let chain = [t0, t1];
282 assert!(verify_chain(&chain, GENESIS_HASH).is_ok());
283 }
284
285 #[test]
286 fn tampering_a_served_trace_is_detectable() {
287 let t0 = sample_trace(GENESIS_HASH, 1);
288 let t1 = sample_trace(&t0.hash().unwrap(), 2);
289 let mut chain = [t0, t1];
290 chain[0].final_.total_cost_usd = 0.0; assert!(verify_chain(&chain, GENESIS_HASH).is_err());
292 }
293
294 #[test]
295 fn roundtrips_through_json() {
296 let t = sample_trace(GENESIS_HASH, 7);
297 let j = serde_json::to_string(&t).unwrap();
298 let back: Trace = serde_json::from_str(&j).unwrap();
299 assert_eq!(t, back);
300 }
301
302 #[test]
307 fn propensity_none_absent_from_json() {
308 let pr = PolicyRef {
309 id: "static@v0".into(),
310 explore: false,
311 propensity: None,
312 };
313 let j = serde_json::to_string(&pr).unwrap();
314 assert!(
315 !j.contains("propensity"),
316 "propensity=None must be omitted (skip_serializing_if): {j}"
317 );
318 }
319
320 #[test]
323 fn old_trace_without_propensity_deserializes_to_none() {
324 let old_json = r#"{"id":"static@v0","explore":false}"#;
325 let pr: PolicyRef = serde_json::from_str(old_json).unwrap();
326 assert_eq!(pr.propensity, None);
327 }
328
329 #[test]
331 fn propensity_some_roundtrips() {
332 let pr = PolicyRef {
333 id: "bandit@v1+eps".into(),
334 explore: true,
335 propensity: Some(0.3),
336 };
337 let j = serde_json::to_string(&pr).unwrap();
338 assert!(
339 j.contains("\"propensity\":0.3"),
340 "expected propensity in: {j}"
341 );
342 let back: PolicyRef = serde_json::from_str(&j).unwrap();
343 assert_eq!(back, pr);
344 }
345}