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}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct RequestInfo {
63 pub api: String,
65 pub prompt_hash: String,
67 pub features: Features,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct Attempt {
74 pub rung: u32,
76 pub model: String,
78 pub provider: String,
80 pub in_tokens: u64,
82 pub out_tokens: u64,
84 pub cost_usd: f64,
86 pub latency_ms: u64,
88 pub gates: Vec<GateResult>,
90 pub verdict: Verdict,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct DeferredVerdict {
97 pub gate_id: String,
99 pub verdict: Verdict,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub score: Option<Score>,
104 pub reported_at: Timestamp,
106 pub reporter: String,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ServedFrom {
114 Attempt,
116 BestAttempt,
118 Error,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct FinalOutcome {
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub served_rung: Option<u32>,
128 pub served_from: ServedFrom,
130 pub total_cost_usd: f64,
132 pub gate_cost_usd: f64,
134 pub total_latency_ms: u64,
136 pub escalations: u32,
138 pub counterfactual_baseline_usd: f64,
140 pub savings_usd: f64,
142}
143
144impl Chained for Trace {
145 fn prev_hash(&self) -> &str {
146 &self.prev_hash
147 }
148}
149
150impl Trace {
151 pub fn hash(&self) -> Result<String> {
157 record_hash(self)
158 }
159
160 pub fn recompute_savings(&mut self) -> f64 {
163 let s = self.final_.counterfactual_baseline_usd - self.final_.total_cost_usd;
164 self.final_.savings_usd = s;
165 s
166 }
167}
168
169impl std::fmt::Display for Trace {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 write!(
172 f,
173 "trace {} rung {:?} served_from {:?} cost ${:.4} saved ${:.4}",
174 self.trace_id,
175 self.final_.served_rung,
176 self.final_.served_from,
177 self.final_.total_cost_usd,
178 self.final_.savings_usd,
179 )
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::features::{Features, TaskKind};
187 use crate::hashchain::{GENESIS_HASH, verify_chain};
188 use crate::verdict::Verdict;
189
190 fn sample_trace(prev_hash: &str, id: u128) -> Trace {
191 let mut t = Trace {
192 trace_id: Uuid::from_u128(id),
193 prev_hash: prev_hash.to_owned(),
194 tenant_id: "acme".into(),
195 session_id: "agent-run-4417".into(),
196 ts: "2026-07-08T15:04:05Z".parse().unwrap(),
197 mode: Mode::Enforce,
198 policy: PolicyRef {
199 id: "static@v0".into(),
200 explore: false,
201 },
202 request: RequestInfo {
203 api: "anthropic.messages".into(),
204 prompt_hash: "deadbeef".into(),
205 features: Features::new(TaskKind::CodeEdit),
206 },
207 attempts: vec![
208 Attempt {
209 rung: 0,
210 model: "anthropic/claude-haiku-4-5".into(),
211 provider: "anthropic".into(),
212 in_tokens: 2000,
213 out_tokens: 700,
214 cost_usd: 0.0007,
215 latency_ms: 900,
216 gates: vec![GateResult::deterministic("cargo-test", Verdict::Fail, 3100)],
217 verdict: Verdict::Fail,
218 },
219 Attempt {
220 rung: 1,
221 model: "anthropic/claude-sonnet-5".into(),
222 provider: "anthropic".into(),
223 in_tokens: 2000,
224 out_tokens: 800,
225 cost_usd: 0.0121,
226 latency_ms: 1200,
227 gates: vec![GateResult::deterministic("cargo-test", Verdict::Pass, 2950)],
228 verdict: Verdict::Pass,
229 },
230 ],
231 deferred: vec![],
232 final_: FinalOutcome {
233 served_rung: Some(1),
234 served_from: ServedFrom::Attempt,
235 total_cost_usd: 0.0128,
236 gate_cost_usd: 0.0,
237 total_latency_ms: 2100,
238 escalations: 1,
239 counterfactual_baseline_usd: 0.0630,
240 savings_usd: 0.0,
241 },
242 };
243 t.recompute_savings();
244 t
245 }
246
247 #[test]
248 fn wire_field_names_are_the_contract() {
249 let t = sample_trace(GENESIS_HASH, 1);
250 let j = serde_json::to_string(&t).unwrap();
251 assert!(j.contains("\"prev_hash\":"));
252 assert!(
253 j.contains("\"final\":"),
254 "the outcome key must serialize as `final`"
255 );
256 assert!(j.contains("\"served_from\":\"attempt\""));
257 assert!(j.contains("\"verdict\":\"fail\""));
258 assert!(j.contains("\"verdict\":\"pass\""));
259 assert!(j.contains("\"counterfactual_baseline_usd\":"));
260 }
261
262 #[test]
263 fn savings_is_baseline_minus_total() {
264 let t = sample_trace(GENESIS_HASH, 1);
265 assert!((t.final_.savings_usd - (0.0630 - 0.0128)).abs() < 1e-12);
266 }
267
268 #[test]
269 fn traces_chain_and_verify() {
270 let t0 = sample_trace(GENESIS_HASH, 1);
271 let t1 = sample_trace(&t0.hash().unwrap(), 2);
272 let chain = [t0, t1];
273 assert!(verify_chain(&chain, GENESIS_HASH).is_ok());
274 }
275
276 #[test]
277 fn tampering_a_served_trace_is_detectable() {
278 let t0 = sample_trace(GENESIS_HASH, 1);
279 let t1 = sample_trace(&t0.hash().unwrap(), 2);
280 let mut chain = [t0, t1];
281 chain[0].final_.total_cost_usd = 0.0; assert!(verify_chain(&chain, GENESIS_HASH).is_err());
283 }
284
285 #[test]
286 fn roundtrips_through_json() {
287 let t = sample_trace(GENESIS_HASH, 7);
288 let j = serde_json::to_string(&t).unwrap();
289 let back: Trace = serde_json::from_str(&j).unwrap();
290 assert_eq!(t, back);
291 }
292}