Skip to main content

firstpass_core/
trace.rs

1//! The audit trace (SPEC §9.1) — the tamper-evident record of one routing decision.
2//!
3//! A trace captures *why* a request went where it did and *what it cost*: the features it was
4//! routed on, every attempt and its gate verdicts, the final served outcome, and the savings
5//! versus always calling the top rung. Each record links to the previous via [`Trace::prev_hash`]
6//! (see [`crate::hashchain`]), so the log is append-only and any tampering is detectable.
7//!
8//! **The serde field names in this module are the wire/audit contract.** External auditors
9//! parse them and re-derive the hash chain; renaming one is a breaking change that requires a
10//! schema/version bump, never a silent edit.
11
12use 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/// A single routing decision, start to finish.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Trace {
24    /// Time-ordered unique id (UUIDv7).
25    pub trace_id: Uuid,
26    /// Hash of the previous record in this chain (or [`crate::hashchain::GENESIS_HASH`]).
27    pub prev_hash: String,
28    /// Tenant this trace belongs to.
29    pub tenant_id: String,
30    /// Session (e.g. an agent run) this request is part of.
31    pub session_id: String,
32    /// When the decision was made.
33    pub ts: Timestamp,
34    /// Serving mode in effect.
35    pub mode: Mode,
36    /// Which policy produced this decision.
37    pub policy: PolicyRef,
38    /// The request that was routed.
39    pub request: RequestInfo,
40    /// Every attempt made, cheapest rung first.
41    pub attempts: Vec<Attempt>,
42    /// Verdicts that arrived after serving (deferred gates, feedback API). Attach over time.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub deferred: Vec<DeferredVerdict>,
45    /// The final served outcome and its economics.
46    #[serde(rename = "final")]
47    pub final_: FinalOutcome,
48}
49
50/// Reference to the policy that produced a decision.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct PolicyRef {
53    /// Policy identity/version, e.g. `"static@v0"` or `"bandit@v3"`.
54    pub id: String,
55    /// Whether this decision was a deliberate exploration draw (bounded, §, only in enforce).
56    #[serde(default)]
57    pub explore: bool,
58    /// The probability the logging policy assigned to the start rung it chose — the
59    /// Horvitz-Thompson denominator for IPS / SNIPS off-policy evaluation.
60    ///
61    /// `None` for deterministic (non-exploring) policies. `skip_serializing_if` keeps old
62    /// trace bytes byte-identical when `None`, preserving hash-chain compatibility with
63    /// existing logs. Only populated when `[escalation.exploration]` is configured.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub propensity: Option<f64>,
66}
67
68/// The routed request, described without its raw content.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct RequestInfo {
71    /// Wire API the caller used, e.g. `"anthropic.messages"` / `"openai.chat"`.
72    pub api: String,
73    /// Salted hash of the prompt — never the prompt text itself.
74    pub prompt_hash: String,
75    /// The versioned feature vector routing keyed on.
76    pub features: Features,
77}
78
79/// One attempt at a rung: the model call plus the gates run against its output.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct Attempt {
82    /// Ladder rung (0 = cheapest).
83    pub rung: u32,
84    /// Model called, `provider/model`.
85    pub model: String,
86    /// Provider segment (denormalized for cheap querying).
87    pub provider: String,
88    /// Input tokens consumed.
89    pub in_tokens: u64,
90    /// Output tokens produced.
91    pub out_tokens: u64,
92    /// USD cost of this model call (excludes gate cost).
93    pub cost_usd: f64,
94    /// Wall-clock latency of the model call.
95    pub latency_ms: u64,
96    /// Gate verdicts for this attempt's output.
97    pub gates: Vec<GateResult>,
98    /// The attempt's overall verdict (the aggregate that drove escalate-or-serve).
99    pub verdict: Verdict,
100}
101
102/// A verdict that arrived after the response was served (deferred gate or downstream feedback).
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct DeferredVerdict {
105    /// Gate/source identity, e.g. `"tests"` or `"feedback:ci"`.
106    pub gate_id: String,
107    /// The late verdict.
108    pub verdict: Verdict,
109    /// Optional score.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub score: Option<Score>,
112    /// When it arrived.
113    pub reported_at: Timestamp,
114    /// Who reported it (a deferred gate, or a feedback-API caller identity).
115    pub reporter: String,
116}
117
118/// Where the served response came from.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ServedFrom {
122    /// A passing attempt (the normal path).
123    Attempt,
124    /// The best attempt seen, served because budget/ladder was exhausted without a pass.
125    BestAttempt,
126    /// No output served; a structured error was returned.
127    Error,
128}
129
130/// The final outcome and economics of a decision.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct FinalOutcome {
133    /// Rung whose output was served (`None` if `served_from = error`).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub served_rung: Option<u32>,
136    /// Provenance of the served response.
137    pub served_from: ServedFrom,
138    /// Total USD spent (all model calls + all gates).
139    pub total_cost_usd: f64,
140    /// USD portion spent on gates alone.
141    pub gate_cost_usd: f64,
142    /// Total wall-clock latency the caller experienced.
143    pub total_latency_ms: u64,
144    /// Number of rung escalations taken.
145    pub escalations: u32,
146    /// What always calling the top rung would have cost (§9.1 counterfactual).
147    pub counterfactual_baseline_usd: f64,
148    /// `counterfactual_baseline_usd - total_cost_usd` — the savings this decision proves.
149    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    /// This record's own hash (SHA-256 over its canonical JSON) — the value the *next*
160    /// record stores as its `prev_hash`.
161    ///
162    /// # Errors
163    /// Returns [`crate::Error::Json`] if the trace cannot be serialized.
164    pub fn hash(&self) -> Result<String> {
165        record_hash(self)
166    }
167
168    /// Compute `savings_usd = baseline - total` and store it, keeping the field consistent
169    /// with the two it is derived from. Returns the savings.
170    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; // forge a cheaper decision after the fact
291        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    // ── propensity backward-compat ────────────────────────────────────────────
303
304    /// Traces where propensity is None must serialize byte-identically to pre-field traces:
305    /// the field must be absent from the JSON, not serialized as `null`.
306    #[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    /// Old JSON without a `propensity` field must deserialize to `propensity: None`
321    /// (via `#[serde(default)]`), keeping old traces readable without schema migration.
322    #[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    /// New JSON with a propensity value round-trips correctly.
330    #[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}