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, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProbeRegime {
29 ConfidentPass,
31 ConfidentFail,
33 Ambiguous,
35}
36
37impl ProbeRegime {
38 #[must_use]
44 pub fn classify(pass_count: u32, k: u32) -> Self {
45 if pass_count == 0 {
46 Self::ConfidentFail
47 } else if pass_count >= k {
48 Self::ConfidentPass
49 } else {
50 Self::Ambiguous
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct ProbeSignal {
59 pub k: u32,
61 pub gate_pass_count: u32,
63 pub regime: ProbeRegime,
65 pub probe_cost_usd: f64,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum ElasticAction {
74 ServeSkip,
77 EscalateNow,
80 Verified,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct ElasticDecision {
90 pub action: ElasticAction,
92 pub signal: f64,
94 pub lambda: f64,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub alpha: Option<f64>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub delta: Option<f64>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub calibration_id: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct Trace {
110 pub trace_id: Uuid,
112 pub prev_hash: String,
114 pub tenant_id: String,
116 pub session_id: String,
118 pub ts: Timestamp,
120 pub mode: Mode,
122 pub policy: PolicyRef,
124 pub request: RequestInfo,
126 pub attempts: Vec<Attempt>,
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub deferred: Vec<DeferredVerdict>,
131 #[serde(rename = "final")]
133 pub final_: FinalOutcome,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub probe: Option<ProbeSignal>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub predicted_pass: Option<f64>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub elastic: Option<ElasticDecision>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct PolicyRef {
155 pub id: String,
157 #[serde(default)]
159 pub explore: bool,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub propensity: Option<f64>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub mode_profile: Option<String>,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct RequestInfo {
178 pub api: String,
180 pub prompt_hash: String,
182 pub features: Features,
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct Attempt {
189 pub rung: u32,
191 pub model: String,
193 pub provider: String,
195 pub in_tokens: u64,
197 pub out_tokens: u64,
199 pub cost_usd: f64,
201 pub latency_ms: u64,
203 pub gates: Vec<GateResult>,
205 pub verdict: Verdict,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct DeferredVerdict {
212 pub gate_id: String,
214 pub verdict: Verdict,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub score: Option<Score>,
219 pub reported_at: Timestamp,
221 pub reporter: String,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum ServedFrom {
229 Attempt,
231 BestAttempt,
233 Error,
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct FinalOutcome {
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub served_rung: Option<u32>,
243 pub served_from: ServedFrom,
245 pub total_cost_usd: f64,
247 pub gate_cost_usd: f64,
249 pub total_latency_ms: u64,
251 pub escalations: u32,
253 pub counterfactual_baseline_usd: f64,
255 pub savings_usd: f64,
257}
258
259impl Chained for Trace {
260 fn prev_hash(&self) -> &str {
261 &self.prev_hash
262 }
263}
264
265impl Trace {
266 pub fn hash(&self) -> Result<String> {
272 record_hash(self)
273 }
274
275 pub fn recompute_savings(&mut self) -> f64 {
278 let s = self.final_.counterfactual_baseline_usd - self.final_.total_cost_usd;
279 self.final_.savings_usd = s;
280 s
281 }
282}
283
284impl std::fmt::Display for Trace {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 write!(
287 f,
288 "trace {} rung {:?} served_from {:?} cost ${:.4} saved ${:.4}",
289 self.trace_id,
290 self.final_.served_rung,
291 self.final_.served_from,
292 self.final_.total_cost_usd,
293 self.final_.savings_usd,
294 )
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::features::{Features, TaskKind};
302 use crate::hashchain::{GENESIS_HASH, verify_chain};
303 use crate::verdict::Verdict;
304
305 fn sample_trace(prev_hash: &str, id: u128) -> Trace {
306 let mut t = Trace {
307 trace_id: Uuid::from_u128(id),
308 prev_hash: prev_hash.to_owned(),
309 tenant_id: "acme".into(),
310 session_id: "agent-run-4417".into(),
311 ts: "2026-07-08T15:04:05Z".parse().unwrap(),
312 mode: Mode::Enforce,
313 policy: PolicyRef {
314 id: "static@v0".into(),
315 explore: false,
316 propensity: None,
317 mode_profile: None,
318 },
319 request: RequestInfo {
320 api: "anthropic.messages".into(),
321 prompt_hash: "deadbeef".into(),
322 features: Features::new(TaskKind::CodeEdit),
323 },
324 attempts: vec![
325 Attempt {
326 rung: 0,
327 model: "anthropic/claude-haiku-4-5".into(),
328 provider: "anthropic".into(),
329 in_tokens: 2000,
330 out_tokens: 700,
331 cost_usd: 0.0007,
332 latency_ms: 900,
333 gates: vec![GateResult::deterministic("cargo-test", Verdict::Fail, 3100)],
334 verdict: Verdict::Fail,
335 },
336 Attempt {
337 rung: 1,
338 model: "anthropic/claude-sonnet-5".into(),
339 provider: "anthropic".into(),
340 in_tokens: 2000,
341 out_tokens: 800,
342 cost_usd: 0.0121,
343 latency_ms: 1200,
344 gates: vec![GateResult::deterministic("cargo-test", Verdict::Pass, 2950)],
345 verdict: Verdict::Pass,
346 },
347 ],
348 deferred: vec![],
349 final_: FinalOutcome {
350 served_rung: Some(1),
351 served_from: ServedFrom::Attempt,
352 total_cost_usd: 0.0128,
353 gate_cost_usd: 0.0,
354 total_latency_ms: 2100,
355 escalations: 1,
356 counterfactual_baseline_usd: 0.0630,
357 savings_usd: 0.0,
358 },
359 probe: None,
360 predicted_pass: None,
361 elastic: None,
362 };
363 t.recompute_savings();
364 t
365 }
366
367 #[test]
368 fn wire_field_names_are_the_contract() {
369 let t = sample_trace(GENESIS_HASH, 1);
370 let j = serde_json::to_string(&t).unwrap();
371 assert!(j.contains("\"prev_hash\":"));
372 assert!(
373 j.contains("\"final\":"),
374 "the outcome key must serialize as `final`"
375 );
376 assert!(j.contains("\"served_from\":\"attempt\""));
377 assert!(j.contains("\"verdict\":\"fail\""));
378 assert!(j.contains("\"verdict\":\"pass\""));
379 assert!(j.contains("\"counterfactual_baseline_usd\":"));
380 }
381
382 #[test]
383 fn savings_is_baseline_minus_total() {
384 let t = sample_trace(GENESIS_HASH, 1);
385 assert!((t.final_.savings_usd - (0.0630 - 0.0128)).abs() < 1e-12);
386 }
387
388 #[test]
389 fn traces_chain_and_verify() {
390 let t0 = sample_trace(GENESIS_HASH, 1);
391 let t1 = sample_trace(&t0.hash().unwrap(), 2);
392 let chain = [t0, t1];
393 assert!(verify_chain(&chain, GENESIS_HASH).is_ok());
394 }
395
396 #[test]
397 fn tampering_a_served_trace_is_detectable() {
398 let t0 = sample_trace(GENESIS_HASH, 1);
399 let t1 = sample_trace(&t0.hash().unwrap(), 2);
400 let mut chain = [t0, t1];
401 chain[0].final_.total_cost_usd = 0.0; assert!(verify_chain(&chain, GENESIS_HASH).is_err());
403 }
404
405 #[test]
406 fn roundtrips_through_json() {
407 let t = sample_trace(GENESIS_HASH, 7);
408 let j = serde_json::to_string(&t).unwrap();
409 let back: Trace = serde_json::from_str(&j).unwrap();
410 assert_eq!(t, back);
411 }
412
413 #[test]
418 fn propensity_none_absent_from_json() {
419 let pr = PolicyRef {
420 id: "static@v0".into(),
421 explore: false,
422 propensity: None,
423 mode_profile: None,
424 };
425 let j = serde_json::to_string(&pr).unwrap();
426 assert!(
427 !j.contains("propensity"),
428 "propensity=None must be omitted (skip_serializing_if): {j}"
429 );
430 }
431
432 #[test]
435 fn old_trace_without_propensity_deserializes_to_none() {
436 let old_json = r#"{"id":"static@v0","explore":false}"#;
437 let pr: PolicyRef = serde_json::from_str(old_json).unwrap();
438 assert_eq!(pr.propensity, None);
439 }
440
441 #[test]
443 fn propensity_some_roundtrips() {
444 let pr = PolicyRef {
445 id: "bandit@v1+eps".into(),
446 explore: true,
447 propensity: Some(0.3),
448 mode_profile: None,
449 };
450 let j = serde_json::to_string(&pr).unwrap();
451 assert!(
452 j.contains("\"propensity\":0.3"),
453 "expected propensity in: {j}"
454 );
455 let back: PolicyRef = serde_json::from_str(&j).unwrap();
456 assert_eq!(back, pr);
457 }
458
459 #[test]
464 fn mode_profile_none_absent_from_json() {
465 let pr = PolicyRef {
466 id: "static@v0".into(),
467 explore: false,
468 propensity: None,
469 mode_profile: None,
470 };
471 let j = serde_json::to_string(&pr).unwrap();
472 assert!(
473 !j.contains("mode_profile"),
474 "mode_profile=None must be omitted (skip_serializing_if): {j}"
475 );
476 }
477
478 #[test]
480 fn old_trace_without_mode_profile_deserializes_to_none() {
481 let old_json = r#"{"id":"static@v0","explore":false}"#;
482 let pr: PolicyRef = serde_json::from_str(old_json).unwrap();
483 assert_eq!(pr.mode_profile, None);
484 }
485
486 #[test]
488 fn mode_profile_some_roundtrips() {
489 let pr = PolicyRef {
490 id: "static@v0".into(),
491 explore: false,
492 propensity: None,
493 mode_profile: Some("quality".into()),
494 };
495 let j = serde_json::to_string(&pr).unwrap();
496 assert!(
497 j.contains("\"mode_profile\":\"quality\""),
498 "expected mode_profile in: {j}"
499 );
500 let back: PolicyRef = serde_json::from_str(&j).unwrap();
501 assert_eq!(back, pr);
502 }
503
504 #[test]
507 fn classify_zero_is_confident_fail() {
508 assert_eq!(ProbeRegime::classify(0, 5), ProbeRegime::ConfidentFail);
509 }
510
511 #[test]
512 fn classify_all_pass_is_confident_pass() {
513 assert_eq!(ProbeRegime::classify(5, 5), ProbeRegime::ConfidentPass);
514 }
515
516 #[test]
517 fn classify_above_k_is_confident_pass() {
518 assert_eq!(ProbeRegime::classify(6, 5), ProbeRegime::ConfidentPass);
520 }
521
522 #[test]
523 fn classify_mixed_is_ambiguous() {
524 assert_eq!(ProbeRegime::classify(1, 5), ProbeRegime::Ambiguous);
525 assert_eq!(ProbeRegime::classify(2, 5), ProbeRegime::Ambiguous);
526 assert_eq!(ProbeRegime::classify(4, 5), ProbeRegime::Ambiguous);
527 }
528
529 #[test]
530 fn classify_k_equals_2_boundaries() {
531 assert_eq!(ProbeRegime::classify(0, 2), ProbeRegime::ConfidentFail);
532 assert_eq!(ProbeRegime::classify(1, 2), ProbeRegime::Ambiguous);
533 assert_eq!(ProbeRegime::classify(2, 2), ProbeRegime::ConfidentPass);
534 }
535
536 #[test]
540 fn probe_none_absent_from_json() {
541 let t = sample_trace(GENESIS_HASH, 1);
542 assert!(t.probe.is_none());
543 let j = serde_json::to_string(&t).unwrap();
544 assert!(
545 !j.contains("\"probe\""),
546 "probe=None must be omitted (skip_serializing_if): {j}"
547 );
548 }
549
550 #[test]
552 fn old_trace_without_probe_deserializes_to_none() {
553 let t = sample_trace(GENESIS_HASH, 1);
555 let j = serde_json::to_string(&t).unwrap();
556 assert!(!j.contains("probe"));
557 let back: Trace = serde_json::from_str(&j).unwrap();
558 assert_eq!(back.probe, None);
559 }
560
561 #[test]
563 fn probe_signal_some_roundtrips() {
564 let mut t = sample_trace(GENESIS_HASH, 1);
565 t.probe = Some(ProbeSignal {
566 k: 5,
567 gate_pass_count: 5,
568 regime: ProbeRegime::ConfidentPass,
569 probe_cost_usd: 0.0003,
570 });
571 let j = serde_json::to_string(&t).unwrap();
572 assert!(j.contains("\"probe\""), "probe=Some must be present: {j}");
573 assert!(j.contains("\"regime\":\"confident_pass\""));
574 assert!(j.contains("\"gate_pass_count\":5"));
575 let back: Trace = serde_json::from_str(&j).unwrap();
576 assert_eq!(back.probe, t.probe);
577 }
578
579 #[test]
581 fn verify_chain_passes_with_probe_field_present() {
582 let mut t0 = sample_trace(GENESIS_HASH, 10);
583 t0.probe = Some(ProbeSignal {
584 k: 3,
585 gate_pass_count: 0,
586 regime: ProbeRegime::ConfidentFail,
587 probe_cost_usd: 0.0001,
588 });
589 let t1 = sample_trace(&t0.hash().unwrap(), 11);
590 let chain = [t0, t1];
591 assert!(
592 verify_chain(&chain, GENESIS_HASH).is_ok(),
593 "chain must verify when probe is present"
594 );
595 }
596
597 #[test]
599 fn tampering_probe_field_is_detectable() {
600 let mut t0 = sample_trace(GENESIS_HASH, 20);
601 t0.probe = Some(ProbeSignal {
602 k: 5,
603 gate_pass_count: 5,
604 regime: ProbeRegime::ConfidentPass,
605 probe_cost_usd: 0.0005,
606 });
607 let t1 = sample_trace(&t0.hash().unwrap(), 21);
608 let mut chain = [t0, t1];
609 chain[0].probe.as_mut().unwrap().gate_pass_count = 0;
611 assert!(
612 verify_chain(&chain, GENESIS_HASH).is_err(),
613 "tampered probe must break the chain"
614 );
615 }
616}