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/// Regime classification from the k-sample visible-pass count (ADR 0008).
22///
23/// The three regimes are validated on MBPP (k=5, n=150): 0/5 pass → 0% oracle-correct (12% of
24/// traffic); 5/5 pass → 99% oracle-correct (65%); 1–4/5 → mixed (23%). Keyed on the pass
25/// *count*, not entropy — the entropy AUC (0.431) was falsified; the count AUC is decisive.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProbeRegime {
29    /// All k samples passed — oracle-correct ≈99% (validated on MBPP).
30    ConfidentPass,
31    /// Zero of k samples passed — oracle-correct ≈0%.
32    ConfidentFail,
33    /// 1..k-1 samples passed — mixed signal; verification information is worth its cost.
34    Ambiguous,
35}
36
37impl ProbeRegime {
38    /// Classify a pass-count into one of three regimes.
39    ///
40    /// - `0` → `ConfidentFail`
41    /// - `pass_count >= k` → `ConfidentPass`
42    /// - otherwise → `Ambiguous`
43    #[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/// The shadow-probe signal recorded on a sampled receipt (ADR 0008 Phase 1). Records the k-sample
56/// gate-pass-count signal and its cost separately from the served cost, so savings math is clean.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct ProbeSignal {
59    /// Number of parallel probe samples drawn.
60    pub k: u32,
61    /// How many of the k samples would have been served under the route's gate/threshold rule.
62    pub gate_pass_count: u32,
63    /// Which of the three validated regimes this request falls into.
64    pub regime: ProbeRegime,
65    /// USD cost of the k shadow model calls — **separate** from `trace.final_.total_cost_usd`
66    /// so per-request savings math is not polluted by measurement cost.
67    pub probe_cost_usd: f64,
68}
69
70/// The action elastic verification (ADR 0008 Phase 3) took on the served rung.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum ElasticAction {
74    /// Visible signal cleared λ → served **without** running the expensive gates (skip authorized
75    /// by the conformal bound). This is the un-verified serve the bound must cover.
76    ServeSkip,
77    /// Visible gates failed (signal at the floor) → escalated **without** paying for the expensive
78    /// gates on a doomed attempt.
79    EscalateNow,
80    /// Ambiguous middle → the expensive gates ran as usual; the gate decided.
81    Verified,
82}
83
84/// Why elastic verification (ADR 0008 Phase 3) skipped or ran the expensive gates on the served
85/// rung — recorded so an auditor can see *why* verification was skipped and check the conformal
86/// bound authorized it. Absent when elastic is off (the default), keeping pre-elastic traces
87/// byte-identical and hash-chain compatible.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct ElasticDecision {
90    /// Which of the three regimes fired.
91    pub action: ElasticAction,
92    /// The visible-gate score that drove the decision (`gate_score` over the cheap gates).
93    pub signal: f64,
94    /// The calibrated skip threshold λ the signal was compared against.
95    pub lambda: f64,
96    /// Target served-failure α the λ was calibrated at (provenance, from config).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub alpha: Option<f64>,
99    /// Confidence (1−δ) the λ was calibrated at (provenance, from config).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub delta: Option<f64>,
102    /// Provenance id of the calibration run that produced λ (from config).
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub calibration_id: Option<String>,
105}
106
107/// A single routing decision, start to finish.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct Trace {
110    /// Time-ordered unique id (UUIDv7).
111    pub trace_id: Uuid,
112    /// Hash of the previous record in this chain (or [`crate::hashchain::GENESIS_HASH`]).
113    pub prev_hash: String,
114    /// Tenant this trace belongs to.
115    pub tenant_id: String,
116    /// Session (e.g. an agent run) this request is part of.
117    pub session_id: String,
118    /// When the decision was made.
119    pub ts: Timestamp,
120    /// Serving mode in effect.
121    pub mode: Mode,
122    /// Which policy produced this decision.
123    pub policy: PolicyRef,
124    /// The request that was routed.
125    pub request: RequestInfo,
126    /// Every attempt made, cheapest rung first.
127    pub attempts: Vec<Attempt>,
128    /// Verdicts that arrived after serving (deferred gates, feedback API). Attach over time.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub deferred: Vec<DeferredVerdict>,
131    /// The final served outcome and its economics.
132    #[serde(rename = "final")]
133    pub final_: FinalOutcome,
134    /// Shadow probe signal (ADR 0008 Phase 1). Absent when probe is off (the default) or when
135    /// this request was not in the configured `sample_rate` — byte-identical to pre-probe traces
136    /// and hash-chain compatible (the `skip_serializing_if` keeps absent = absent).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub probe: Option<ProbeSignal>,
139    /// Shadow prediction of `P(gate-pass)` for the start rung (ADR 0008 Phase 2), from the
140    /// per-query predictor. Absent when the predictor is off (the default) — byte-identical to
141    /// pre-predictor traces and hash-chain compatible. Recorded but never acted on in this phase.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub predicted_pass: Option<f64>,
144    /// Elastic verification decision (ADR 0008 Phase 3) on the served rung: why the expensive gates
145    /// were skipped or run, plus the λ / calibration provenance the skip was authorized under.
146    /// Absent when elastic is off (the default) — byte-identical to pre-elastic traces and
147    /// hash-chain compatible (the `skip_serializing_if` keeps absent = absent).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub elastic: Option<ElasticDecision>,
150}
151
152/// Reference to the policy that produced a decision.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct PolicyRef {
155    /// Policy identity/version, e.g. `"static@v0"` or `"bandit@v3"`.
156    pub id: String,
157    /// Whether this decision was a deliberate exploration draw (bounded, §, only in enforce).
158    #[serde(default)]
159    pub explore: bool,
160    /// The probability the logging policy assigned to the start rung it chose — the
161    /// Horvitz-Thompson denominator for IPS / SNIPS off-policy evaluation.
162    ///
163    /// `None` for deterministic (non-exploring) policies. `skip_serializing_if` keeps old
164    /// trace bytes byte-identical when `None`, preserving hash-chain compatibility with
165    /// existing logs. Only populated when `[escalation.exploration]` is configured.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub propensity: Option<f64>,
168    /// Resolved routing-mode profile when it was explicitly set (header / route / global env).
169    /// `None` means `Balanced` was in effect (the default, no override active). Absent from
170    /// the JSON when `None` → byte-identical serialization for all existing traces.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub mode_profile: Option<String>,
173}
174
175/// The routed request, described without its raw content.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct RequestInfo {
178    /// Wire API the caller used, e.g. `"anthropic.messages"` / `"openai.chat"`.
179    pub api: String,
180    /// Salted hash of the prompt — never the prompt text itself.
181    pub prompt_hash: String,
182    /// The versioned feature vector routing keyed on.
183    pub features: Features,
184}
185
186/// One attempt at a rung: the model call plus the gates run against its output.
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct Attempt {
189    /// Ladder rung (0 = cheapest).
190    pub rung: u32,
191    /// Model called, `provider/model`.
192    pub model: String,
193    /// Provider segment (denormalized for cheap querying).
194    pub provider: String,
195    /// Input tokens consumed.
196    pub in_tokens: u64,
197    /// Output tokens produced.
198    pub out_tokens: u64,
199    /// USD cost of this model call (excludes gate cost).
200    pub cost_usd: f64,
201    /// Wall-clock latency of the model call.
202    pub latency_ms: u64,
203    /// Gate verdicts for this attempt's output.
204    pub gates: Vec<GateResult>,
205    /// The attempt's overall verdict (the aggregate that drove escalate-or-serve).
206    pub verdict: Verdict,
207}
208
209/// A verdict that arrived after the response was served (deferred gate or downstream feedback).
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct DeferredVerdict {
212    /// Gate/source identity, e.g. `"tests"` or `"feedback:ci"`.
213    pub gate_id: String,
214    /// The late verdict.
215    pub verdict: Verdict,
216    /// Optional score.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub score: Option<Score>,
219    /// When it arrived.
220    pub reported_at: Timestamp,
221    /// Who reported it (a deferred gate, or a feedback-API caller identity).
222    pub reporter: String,
223}
224
225/// Where the served response came from.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum ServedFrom {
229    /// A passing attempt (the normal path).
230    Attempt,
231    /// The best attempt seen, served because budget/ladder was exhausted without a pass.
232    BestAttempt,
233    /// No output served; a structured error was returned.
234    Error,
235}
236
237/// The final outcome and economics of a decision.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct FinalOutcome {
240    /// Rung whose output was served (`None` if `served_from = error`).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub served_rung: Option<u32>,
243    /// Provenance of the served response.
244    pub served_from: ServedFrom,
245    /// Total USD spent (all model calls + all gates).
246    pub total_cost_usd: f64,
247    /// USD portion spent on gates alone.
248    pub gate_cost_usd: f64,
249    /// Total wall-clock latency the caller experienced.
250    pub total_latency_ms: u64,
251    /// Number of rung escalations taken.
252    pub escalations: u32,
253    /// What always calling the top rung would have cost (§9.1 counterfactual).
254    pub counterfactual_baseline_usd: f64,
255    /// `counterfactual_baseline_usd - total_cost_usd` — the savings this decision proves.
256    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    /// This record's own hash (SHA-256 over its canonical JSON) — the value the *next*
267    /// record stores as its `prev_hash`.
268    ///
269    /// # Errors
270    /// Returns [`crate::Error::Json`] if the trace cannot be serialized.
271    pub fn hash(&self) -> Result<String> {
272        record_hash(self)
273    }
274
275    /// Compute `savings_usd = baseline - total` and store it, keeping the field consistent
276    /// with the two it is derived from. Returns the savings.
277    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; // forge a cheaper decision after the fact
402        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    // ── propensity backward-compat ────────────────────────────────────────────
414
415    /// Traces where propensity is None must serialize byte-identically to pre-field traces:
416    /// the field must be absent from the JSON, not serialized as `null`.
417    #[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    /// Old JSON without a `propensity` field must deserialize to `propensity: None`
433    /// (via `#[serde(default)]`), keeping old traces readable without schema migration.
434    #[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    /// New JSON with a propensity value round-trips correctly.
442    #[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    // ── mode_profile backward-compat ─────────────────────────────────────────
460
461    /// `mode_profile = None` must not appear in the serialized JSON — byte-identical for
462    /// existing traces (Balanced is the default and must be invisible).
463    #[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    /// Old JSON without a `mode_profile` field deserializes to `mode_profile: None`.
479    #[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    /// `mode_profile = Some(...)` round-trips correctly.
487    #[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    // ── ProbeRegime::classify ─────────────────────────────────────────────────
505
506    #[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        // pass_count > k is technically impossible but the rule handles it gracefully.
519        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    // ── ProbeSignal serde backward-compat ────────────────────────────────────
537
538    /// `probe = None` must be absent from the JSON — byte-identical to pre-probe traces.
539    #[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    /// Old JSON without a `probe` field deserializes cleanly (probe defaults to None).
551    #[test]
552    fn old_trace_without_probe_deserializes_to_none() {
553        // Use the canonical JSON of a probe-None trace as a stand-in for old traces.
554        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    /// `probe = Some(...)` round-trips correctly.
562    #[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    /// Hash chain still verifies when `probe` is present — field participates in the hash.
580    #[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    /// Tampering the probe field is detectable via the hash chain.
598    #[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        // Tamper the probe field.
610        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}