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, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProbeRegime {
ConfidentPass,
ConfidentFail,
Ambiguous,
}
impl ProbeRegime {
#[must_use]
pub fn classify(pass_count: u32, k: u32) -> Self {
if pass_count == 0 {
Self::ConfidentFail
} else if pass_count >= k {
Self::ConfidentPass
} else {
Self::Ambiguous
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProbeSignal {
pub k: u32,
pub gate_pass_count: u32,
pub regime: ProbeRegime,
pub probe_cost_usd: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ElasticAction {
ServeSkip,
EscalateNow,
Verified,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ElasticDecision {
pub action: ElasticAction,
pub signal: f64,
pub lambda: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alpha: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calibration_id: Option<String>,
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub probe: Option<ProbeSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub predicted_pass: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elastic: Option<ElasticDecision>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PolicyRef {
pub id: String,
#[serde(default)]
pub explore: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub propensity: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode_profile: Option<String>,
}
#[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,
propensity: None,
mode_profile: None,
},
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,
},
probe: None,
predicted_pass: None,
elastic: None,
};
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);
}
#[test]
fn propensity_none_absent_from_json() {
let pr = PolicyRef {
id: "static@v0".into(),
explore: false,
propensity: None,
mode_profile: None,
};
let j = serde_json::to_string(&pr).unwrap();
assert!(
!j.contains("propensity"),
"propensity=None must be omitted (skip_serializing_if): {j}"
);
}
#[test]
fn old_trace_without_propensity_deserializes_to_none() {
let old_json = r#"{"id":"static@v0","explore":false}"#;
let pr: PolicyRef = serde_json::from_str(old_json).unwrap();
assert_eq!(pr.propensity, None);
}
#[test]
fn propensity_some_roundtrips() {
let pr = PolicyRef {
id: "bandit@v1+eps".into(),
explore: true,
propensity: Some(0.3),
mode_profile: None,
};
let j = serde_json::to_string(&pr).unwrap();
assert!(
j.contains("\"propensity\":0.3"),
"expected propensity in: {j}"
);
let back: PolicyRef = serde_json::from_str(&j).unwrap();
assert_eq!(back, pr);
}
#[test]
fn mode_profile_none_absent_from_json() {
let pr = PolicyRef {
id: "static@v0".into(),
explore: false,
propensity: None,
mode_profile: None,
};
let j = serde_json::to_string(&pr).unwrap();
assert!(
!j.contains("mode_profile"),
"mode_profile=None must be omitted (skip_serializing_if): {j}"
);
}
#[test]
fn old_trace_without_mode_profile_deserializes_to_none() {
let old_json = r#"{"id":"static@v0","explore":false}"#;
let pr: PolicyRef = serde_json::from_str(old_json).unwrap();
assert_eq!(pr.mode_profile, None);
}
#[test]
fn mode_profile_some_roundtrips() {
let pr = PolicyRef {
id: "static@v0".into(),
explore: false,
propensity: None,
mode_profile: Some("quality".into()),
};
let j = serde_json::to_string(&pr).unwrap();
assert!(
j.contains("\"mode_profile\":\"quality\""),
"expected mode_profile in: {j}"
);
let back: PolicyRef = serde_json::from_str(&j).unwrap();
assert_eq!(back, pr);
}
#[test]
fn classify_zero_is_confident_fail() {
assert_eq!(ProbeRegime::classify(0, 5), ProbeRegime::ConfidentFail);
}
#[test]
fn classify_all_pass_is_confident_pass() {
assert_eq!(ProbeRegime::classify(5, 5), ProbeRegime::ConfidentPass);
}
#[test]
fn classify_above_k_is_confident_pass() {
assert_eq!(ProbeRegime::classify(6, 5), ProbeRegime::ConfidentPass);
}
#[test]
fn classify_mixed_is_ambiguous() {
assert_eq!(ProbeRegime::classify(1, 5), ProbeRegime::Ambiguous);
assert_eq!(ProbeRegime::classify(2, 5), ProbeRegime::Ambiguous);
assert_eq!(ProbeRegime::classify(4, 5), ProbeRegime::Ambiguous);
}
#[test]
fn classify_k_equals_2_boundaries() {
assert_eq!(ProbeRegime::classify(0, 2), ProbeRegime::ConfidentFail);
assert_eq!(ProbeRegime::classify(1, 2), ProbeRegime::Ambiguous);
assert_eq!(ProbeRegime::classify(2, 2), ProbeRegime::ConfidentPass);
}
#[test]
fn probe_none_absent_from_json() {
let t = sample_trace(GENESIS_HASH, 1);
assert!(t.probe.is_none());
let j = serde_json::to_string(&t).unwrap();
assert!(
!j.contains("\"probe\""),
"probe=None must be omitted (skip_serializing_if): {j}"
);
}
#[test]
fn old_trace_without_probe_deserializes_to_none() {
let t = sample_trace(GENESIS_HASH, 1);
let j = serde_json::to_string(&t).unwrap();
assert!(!j.contains("probe"));
let back: Trace = serde_json::from_str(&j).unwrap();
assert_eq!(back.probe, None);
}
#[test]
fn probe_signal_some_roundtrips() {
let mut t = sample_trace(GENESIS_HASH, 1);
t.probe = Some(ProbeSignal {
k: 5,
gate_pass_count: 5,
regime: ProbeRegime::ConfidentPass,
probe_cost_usd: 0.0003,
});
let j = serde_json::to_string(&t).unwrap();
assert!(j.contains("\"probe\""), "probe=Some must be present: {j}");
assert!(j.contains("\"regime\":\"confident_pass\""));
assert!(j.contains("\"gate_pass_count\":5"));
let back: Trace = serde_json::from_str(&j).unwrap();
assert_eq!(back.probe, t.probe);
}
#[test]
fn verify_chain_passes_with_probe_field_present() {
let mut t0 = sample_trace(GENESIS_HASH, 10);
t0.probe = Some(ProbeSignal {
k: 3,
gate_pass_count: 0,
regime: ProbeRegime::ConfidentFail,
probe_cost_usd: 0.0001,
});
let t1 = sample_trace(&t0.hash().unwrap(), 11);
let chain = [t0, t1];
assert!(
verify_chain(&chain, GENESIS_HASH).is_ok(),
"chain must verify when probe is present"
);
}
#[test]
fn tampering_probe_field_is_detectable() {
let mut t0 = sample_trace(GENESIS_HASH, 20);
t0.probe = Some(ProbeSignal {
k: 5,
gate_pass_count: 5,
regime: ProbeRegime::ConfidentPass,
probe_cost_usd: 0.0005,
});
let t1 = sample_trace(&t0.hash().unwrap(), 21);
let mut chain = [t0, t1];
chain[0].probe.as_mut().unwrap().gate_pass_count = 0;
assert!(
verify_chain(&chain, GENESIS_HASH).is_err(),
"tampered probe must break the chain"
);
}
}