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/// The rollout arm this request landed in (ADR 0009 D1), recorded so an auditor can re-derive
108/// arm membership from the receipt alone. Absent when the route has no rollout — byte-identical
109/// What Firstpass *would* have done for an observed request (ADR 0009 D2).
110///
111/// This describes a counterfactual that was never served, so it must never enter the conformal
112/// calibration set for served failures — it is a separate estimate with its own sample size.
113/// Pooling it with served outcomes would let a projection contaminate the published bound.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct ShadowSignal {
116 /// Rung that would have been served, if any gate passed.
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub would_serve_rung: Option<u32>,
119 /// Whether the ladder would have produced a servable answer at all.
120 pub would_pass: bool,
121 /// What the shadow evaluation cost to run.
122 pub projected_cost_usd: f64,
123 /// What the request actually cost upstream, for comparison.
124 pub actual_cost_usd: f64,
125 /// Present when shadow did not run, saying why — a skipped measurement is recorded rather
126 /// than silently absent, so a projection that has stopped tracking is visible in the record.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub skipped: Option<String>,
129}
130
131/// to pre-rollout traces and hash-chain compatible.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct RolloutRecord {
134 /// Configured percent at decision time. Recorded because it changes as an operator ramps,
135 /// and a bound computed over a window spanning a ramp must be able to see that.
136 pub percent: f64,
137 /// Which identity was held constant (`session` / `request` / `tenant`).
138 pub key: String,
139 /// The bucket this request fell in, within `0..10_000`.
140 pub bucket: u32,
141 /// Whether this request enforced. The observed arm is not a failure — it is the control.
142 pub enforced: bool,
143}
144
145/// A single routing decision, start to finish.
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct Trace {
148 /// Time-ordered unique id (UUIDv7).
149 pub trace_id: Uuid,
150 /// Hash of the previous record in this chain (or [`crate::hashchain::GENESIS_HASH`]).
151 pub prev_hash: String,
152 /// Tenant this trace belongs to.
153 pub tenant_id: String,
154 /// Session (e.g. an agent run) this request is part of.
155 pub session_id: String,
156 /// When the decision was made.
157 pub ts: Timestamp,
158 /// Serving mode in effect.
159 pub mode: Mode,
160 /// Which policy produced this decision.
161 pub policy: PolicyRef,
162 /// The request that was routed.
163 pub request: RequestInfo,
164 /// Every attempt made, cheapest rung first.
165 pub attempts: Vec<Attempt>,
166 /// Verdicts that arrived after serving (deferred gates, feedback API). Attach over time.
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub deferred: Vec<DeferredVerdict>,
169 /// The final served outcome and its economics.
170 #[serde(rename = "final")]
171 pub final_: FinalOutcome,
172 /// Shadow probe signal (ADR 0008 Phase 1). Absent when probe is off (the default) or when
173 /// this request was not in the configured `sample_rate` — byte-identical to pre-probe traces
174 /// and hash-chain compatible (the `skip_serializing_if` keeps absent = absent).
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub probe: Option<ProbeSignal>,
177 /// Which rollout arm this request was in (ADR 0009 D1). Absent when the route has no
178 /// rollout configured.
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub rollout: Option<RolloutRecord>,
181 /// Counterfactual signal from shadow scoring (ADR 0009 D2). Absent unless shadow is enabled.
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub shadow: Option<ShadowSignal>,
184 /// Index of the route that handled this request, so a downstream outcome arriving later can
185 /// be attributed to the route that actually produced it (ADR 0009 D3).
186 ///
187 /// Without this the guardrail has to pool every tenant's outcomes onto one route, which in a
188 /// multi-route config means a failing route can be masked by healthy siblings — the guard
189 /// would sit green while the thing it guards degrades. Absent on traces written before this
190 /// field existed; the guardrail treats absent as route 0, its prior behaviour.
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub route_ix: Option<u32>,
193 /// Shadow prediction of `P(gate-pass)` for the start rung (ADR 0008 Phase 2), from the
194 /// per-query predictor. Absent when the predictor is off (the default) — byte-identical to
195 /// pre-predictor traces and hash-chain compatible. Recorded but never acted on in this phase.
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub predicted_pass: Option<f64>,
198 /// Elastic verification decision (ADR 0008 Phase 3) on the served rung: why the expensive gates
199 /// were skipped or run, plus the λ / calibration provenance the skip was authorized under.
200 /// Absent when elastic is off (the default) — byte-identical to pre-elastic traces and
201 /// hash-chain compatible (the `skip_serializing_if` keeps absent = absent).
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub elastic: Option<ElasticDecision>,
204}
205
206/// Reference to the policy that produced a decision.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct PolicyRef {
209 /// Policy identity/version, e.g. `"static@v0"` or `"bandit@v3"`.
210 pub id: String,
211 /// Whether this decision was a deliberate exploration draw (bounded, §, only in enforce).
212 #[serde(default)]
213 pub explore: bool,
214 /// The probability the logging policy assigned to the start rung it chose — the
215 /// Horvitz-Thompson denominator for IPS / SNIPS off-policy evaluation.
216 ///
217 /// `None` for deterministic (non-exploring) policies. `skip_serializing_if` keeps old
218 /// trace bytes byte-identical when `None`, preserving hash-chain compatibility with
219 /// existing logs. Only populated when `[escalation.exploration]` is configured.
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub propensity: Option<f64>,
222 /// Resolved routing-mode profile when it was explicitly set (header / route / global env).
223 /// `None` means `Balanced` was in effect (the default, no override active). Absent from
224 /// the JSON when `None` → byte-identical serialization for all existing traces.
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub mode_profile: Option<String>,
227}
228
229/// The routed request, described without its raw content.
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231pub struct RequestInfo {
232 /// Wire API the caller used, e.g. `"anthropic.messages"` / `"openai.chat"`.
233 pub api: String,
234 /// Salted hash of the prompt — never the prompt text itself.
235 pub prompt_hash: String,
236 /// The versioned feature vector routing keyed on.
237 pub features: Features,
238}
239
240/// One attempt at a rung: the model call plus the gates run against its output.
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
242pub struct Attempt {
243 /// Ladder rung (0 = cheapest).
244 pub rung: u32,
245 /// Model called, `provider/model`.
246 pub model: String,
247 /// Provider segment (denormalized for cheap querying).
248 pub provider: String,
249 /// Input tokens consumed.
250 pub in_tokens: u64,
251 /// Output tokens produced.
252 pub out_tokens: u64,
253 /// USD cost of this model call (excludes gate cost).
254 pub cost_usd: f64,
255 /// Wall-clock latency of the model call.
256 pub latency_ms: u64,
257 /// Gate verdicts for this attempt's output.
258 pub gates: Vec<GateResult>,
259 /// The attempt's overall verdict (the aggregate that drove escalate-or-serve).
260 pub verdict: Verdict,
261}
262
263/// A verdict that arrived after the response was served (deferred gate or downstream feedback).
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct DeferredVerdict {
266 /// Gate/source identity, e.g. `"tests"` or `"feedback:ci"`.
267 pub gate_id: String,
268 /// The late verdict.
269 pub verdict: Verdict,
270 /// Optional score.
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub score: Option<Score>,
273 /// When it arrived.
274 pub reported_at: Timestamp,
275 /// Who reported it (a deferred gate, or a feedback-API caller identity).
276 pub reporter: String,
277}
278
279/// Where the served response came from.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(rename_all = "snake_case")]
282pub enum ServedFrom {
283 /// A passing attempt (the normal path).
284 Attempt,
285 /// The best attempt seen, served because budget/ladder was exhausted without a pass.
286 BestAttempt,
287 /// No output served; a structured error was returned.
288 Error,
289}
290
291/// The final outcome and economics of a decision.
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293pub struct FinalOutcome {
294 /// Rung whose output was served (`None` if `served_from = error`).
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub served_rung: Option<u32>,
297 /// Provenance of the served response.
298 pub served_from: ServedFrom,
299 /// Total USD spent (all model calls + all gates).
300 pub total_cost_usd: f64,
301 /// USD portion spent on gates alone.
302 pub gate_cost_usd: f64,
303 /// Total wall-clock latency the caller experienced.
304 pub total_latency_ms: u64,
305 /// Number of rung escalations taken.
306 pub escalations: u32,
307 /// What always calling the top rung would have cost (§9.1 counterfactual).
308 pub counterfactual_baseline_usd: f64,
309 /// `counterfactual_baseline_usd - total_cost_usd` — the savings this decision proves.
310 pub savings_usd: f64,
311}
312
313impl Chained for Trace {
314 fn prev_hash(&self) -> &str {
315 &self.prev_hash
316 }
317}
318
319impl Trace {
320 /// This record's own hash (SHA-256 over its canonical JSON) — the value the *next*
321 /// record stores as its `prev_hash`.
322 ///
323 /// # Errors
324 /// Returns [`crate::Error::Json`] if the trace cannot be serialized.
325 pub fn hash(&self) -> Result<String> {
326 record_hash(self)
327 }
328
329 /// Compute `savings_usd = baseline - total` and store it, keeping the field consistent
330 /// with the two it is derived from. Returns the savings.
331 pub fn recompute_savings(&mut self) -> f64 {
332 let s = self.final_.counterfactual_baseline_usd - self.final_.total_cost_usd;
333 self.final_.savings_usd = s;
334 s
335 }
336}
337
338impl std::fmt::Display for Trace {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 write!(
341 f,
342 "trace {} rung {:?} served_from {:?} cost ${:.4} saved ${:.4}",
343 self.trace_id,
344 self.final_.served_rung,
345 self.final_.served_from,
346 self.final_.total_cost_usd,
347 self.final_.savings_usd,
348 )
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::features::{Features, TaskKind};
356 use crate::hashchain::{GENESIS_HASH, verify_chain};
357 use crate::verdict::Verdict;
358
359 fn sample_trace(prev_hash: &str, id: u128) -> Trace {
360 let mut t = Trace {
361 trace_id: Uuid::from_u128(id),
362 prev_hash: prev_hash.to_owned(),
363 tenant_id: "acme".into(),
364 session_id: "agent-run-4417".into(),
365 ts: "2026-07-08T15:04:05Z".parse().unwrap(),
366 mode: Mode::Enforce,
367 policy: PolicyRef {
368 id: "static@v0".into(),
369 explore: false,
370 propensity: None,
371 mode_profile: None,
372 },
373 request: RequestInfo {
374 api: "anthropic.messages".into(),
375 prompt_hash: "deadbeef".into(),
376 features: Features::new(TaskKind::CodeEdit),
377 },
378 attempts: vec![
379 Attempt {
380 rung: 0,
381 model: "anthropic/claude-haiku-4-5".into(),
382 provider: "anthropic".into(),
383 in_tokens: 2000,
384 out_tokens: 700,
385 cost_usd: 0.0007,
386 latency_ms: 900,
387 gates: vec![GateResult::deterministic("cargo-test", Verdict::Fail, 3100)],
388 verdict: Verdict::Fail,
389 },
390 Attempt {
391 rung: 1,
392 model: "anthropic/claude-sonnet-5".into(),
393 provider: "anthropic".into(),
394 in_tokens: 2000,
395 out_tokens: 800,
396 cost_usd: 0.0121,
397 latency_ms: 1200,
398 gates: vec![GateResult::deterministic("cargo-test", Verdict::Pass, 2950)],
399 verdict: Verdict::Pass,
400 },
401 ],
402 deferred: vec![],
403 final_: FinalOutcome {
404 served_rung: Some(1),
405 served_from: ServedFrom::Attempt,
406 total_cost_usd: 0.0128,
407 gate_cost_usd: 0.0,
408 total_latency_ms: 2100,
409 escalations: 1,
410 counterfactual_baseline_usd: 0.0630,
411 savings_usd: 0.0,
412 },
413 probe: None,
414 rollout: None,
415 shadow: None,
416 route_ix: None,
417 predicted_pass: None,
418 elastic: None,
419 };
420 t.recompute_savings();
421 t
422 }
423
424 #[test]
425 fn wire_field_names_are_the_contract() {
426 let t = sample_trace(GENESIS_HASH, 1);
427 let j = serde_json::to_string(&t).unwrap();
428 assert!(j.contains("\"prev_hash\":"));
429 assert!(
430 j.contains("\"final\":"),
431 "the outcome key must serialize as `final`"
432 );
433 assert!(j.contains("\"served_from\":\"attempt\""));
434 assert!(j.contains("\"verdict\":\"fail\""));
435 assert!(j.contains("\"verdict\":\"pass\""));
436 assert!(j.contains("\"counterfactual_baseline_usd\":"));
437 }
438
439 #[test]
440 fn savings_is_baseline_minus_total() {
441 let t = sample_trace(GENESIS_HASH, 1);
442 assert!((t.final_.savings_usd - (0.0630 - 0.0128)).abs() < 1e-12);
443 }
444
445 #[test]
446 fn traces_chain_and_verify() {
447 let t0 = sample_trace(GENESIS_HASH, 1);
448 let t1 = sample_trace(&t0.hash().unwrap(), 2);
449 let chain = [t0, t1];
450 assert!(verify_chain(&chain, GENESIS_HASH).is_ok());
451 }
452
453 #[test]
454 fn tampering_a_served_trace_is_detectable() {
455 let t0 = sample_trace(GENESIS_HASH, 1);
456 let t1 = sample_trace(&t0.hash().unwrap(), 2);
457 let mut chain = [t0, t1];
458 chain[0].final_.total_cost_usd = 0.0; // forge a cheaper decision after the fact
459 assert!(verify_chain(&chain, GENESIS_HASH).is_err());
460 }
461
462 #[test]
463 fn roundtrips_through_json() {
464 let t = sample_trace(GENESIS_HASH, 7);
465 let j = serde_json::to_string(&t).unwrap();
466 let back: Trace = serde_json::from_str(&j).unwrap();
467 assert_eq!(t, back);
468 }
469
470 // ── propensity backward-compat ────────────────────────────────────────────
471
472 /// Traces where propensity is None must serialize byte-identically to pre-field traces:
473 /// the field must be absent from the JSON, not serialized as `null`.
474 #[test]
475 fn propensity_none_absent_from_json() {
476 let pr = PolicyRef {
477 id: "static@v0".into(),
478 explore: false,
479 propensity: None,
480 mode_profile: None,
481 };
482 let j = serde_json::to_string(&pr).unwrap();
483 assert!(
484 !j.contains("propensity"),
485 "propensity=None must be omitted (skip_serializing_if): {j}"
486 );
487 }
488
489 /// Old JSON without a `propensity` field must deserialize to `propensity: None`
490 /// (via `#[serde(default)]`), keeping old traces readable without schema migration.
491 #[test]
492 fn old_trace_without_propensity_deserializes_to_none() {
493 let old_json = r#"{"id":"static@v0","explore":false}"#;
494 let pr: PolicyRef = serde_json::from_str(old_json).unwrap();
495 assert_eq!(pr.propensity, None);
496 }
497
498 /// New JSON with a propensity value round-trips correctly.
499 #[test]
500 fn propensity_some_roundtrips() {
501 let pr = PolicyRef {
502 id: "bandit@v1+eps".into(),
503 explore: true,
504 propensity: Some(0.3),
505 mode_profile: None,
506 };
507 let j = serde_json::to_string(&pr).unwrap();
508 assert!(
509 j.contains("\"propensity\":0.3"),
510 "expected propensity in: {j}"
511 );
512 let back: PolicyRef = serde_json::from_str(&j).unwrap();
513 assert_eq!(back, pr);
514 }
515
516 // ── mode_profile backward-compat ─────────────────────────────────────────
517
518 /// `mode_profile = None` must not appear in the serialized JSON — byte-identical for
519 /// existing traces (Balanced is the default and must be invisible).
520 #[test]
521 fn mode_profile_none_absent_from_json() {
522 let pr = PolicyRef {
523 id: "static@v0".into(),
524 explore: false,
525 propensity: None,
526 mode_profile: None,
527 };
528 let j = serde_json::to_string(&pr).unwrap();
529 assert!(
530 !j.contains("mode_profile"),
531 "mode_profile=None must be omitted (skip_serializing_if): {j}"
532 );
533 }
534
535 /// Old JSON without a `mode_profile` field deserializes to `mode_profile: None`.
536 #[test]
537 fn old_trace_without_mode_profile_deserializes_to_none() {
538 let old_json = r#"{"id":"static@v0","explore":false}"#;
539 let pr: PolicyRef = serde_json::from_str(old_json).unwrap();
540 assert_eq!(pr.mode_profile, None);
541 }
542
543 /// `mode_profile = Some(...)` round-trips correctly.
544 #[test]
545 fn mode_profile_some_roundtrips() {
546 let pr = PolicyRef {
547 id: "static@v0".into(),
548 explore: false,
549 propensity: None,
550 mode_profile: Some("quality".into()),
551 };
552 let j = serde_json::to_string(&pr).unwrap();
553 assert!(
554 j.contains("\"mode_profile\":\"quality\""),
555 "expected mode_profile in: {j}"
556 );
557 let back: PolicyRef = serde_json::from_str(&j).unwrap();
558 assert_eq!(back, pr);
559 }
560
561 // ── ProbeRegime::classify ─────────────────────────────────────────────────
562
563 #[test]
564 fn classify_zero_is_confident_fail() {
565 assert_eq!(ProbeRegime::classify(0, 5), ProbeRegime::ConfidentFail);
566 }
567
568 #[test]
569 fn classify_all_pass_is_confident_pass() {
570 assert_eq!(ProbeRegime::classify(5, 5), ProbeRegime::ConfidentPass);
571 }
572
573 #[test]
574 fn classify_above_k_is_confident_pass() {
575 // pass_count > k is technically impossible but the rule handles it gracefully.
576 assert_eq!(ProbeRegime::classify(6, 5), ProbeRegime::ConfidentPass);
577 }
578
579 #[test]
580 fn classify_mixed_is_ambiguous() {
581 assert_eq!(ProbeRegime::classify(1, 5), ProbeRegime::Ambiguous);
582 assert_eq!(ProbeRegime::classify(2, 5), ProbeRegime::Ambiguous);
583 assert_eq!(ProbeRegime::classify(4, 5), ProbeRegime::Ambiguous);
584 }
585
586 #[test]
587 fn classify_k_equals_2_boundaries() {
588 assert_eq!(ProbeRegime::classify(0, 2), ProbeRegime::ConfidentFail);
589 assert_eq!(ProbeRegime::classify(1, 2), ProbeRegime::Ambiguous);
590 assert_eq!(ProbeRegime::classify(2, 2), ProbeRegime::ConfidentPass);
591 }
592
593 // ── ProbeSignal serde backward-compat ────────────────────────────────────
594
595 /// `probe = None` must be absent from the JSON — byte-identical to pre-probe traces.
596 #[test]
597 fn probe_none_absent_from_json() {
598 let t = sample_trace(GENESIS_HASH, 1);
599 assert!(t.probe.is_none());
600 let j = serde_json::to_string(&t).unwrap();
601 assert!(
602 !j.contains("\"probe\""),
603 "probe=None must be omitted (skip_serializing_if): {j}"
604 );
605 }
606
607 /// Old JSON without a `probe` field deserializes cleanly (probe defaults to None).
608 #[test]
609 fn old_trace_without_probe_deserializes_to_none() {
610 // Use the canonical JSON of a probe-None trace as a stand-in for old traces.
611 let t = sample_trace(GENESIS_HASH, 1);
612 let j = serde_json::to_string(&t).unwrap();
613 assert!(!j.contains("probe"));
614 let back: Trace = serde_json::from_str(&j).unwrap();
615 assert_eq!(back.probe, None);
616 }
617
618 /// `probe = Some(...)` round-trips correctly.
619 #[test]
620 fn probe_signal_some_roundtrips() {
621 let mut t = sample_trace(GENESIS_HASH, 1);
622 t.probe = Some(ProbeSignal {
623 k: 5,
624 gate_pass_count: 5,
625 regime: ProbeRegime::ConfidentPass,
626 probe_cost_usd: 0.0003,
627 });
628 let j = serde_json::to_string(&t).unwrap();
629 assert!(j.contains("\"probe\""), "probe=Some must be present: {j}");
630 assert!(j.contains("\"regime\":\"confident_pass\""));
631 assert!(j.contains("\"gate_pass_count\":5"));
632 let back: Trace = serde_json::from_str(&j).unwrap();
633 assert_eq!(back.probe, t.probe);
634 }
635
636 /// Hash chain still verifies when `probe` is present — field participates in the hash.
637 #[test]
638 fn verify_chain_passes_with_probe_field_present() {
639 let mut t0 = sample_trace(GENESIS_HASH, 10);
640 t0.probe = Some(ProbeSignal {
641 k: 3,
642 gate_pass_count: 0,
643 regime: ProbeRegime::ConfidentFail,
644 probe_cost_usd: 0.0001,
645 });
646 let t1 = sample_trace(&t0.hash().unwrap(), 11);
647 let chain = [t0, t1];
648 assert!(
649 verify_chain(&chain, GENESIS_HASH).is_ok(),
650 "chain must verify when probe is present"
651 );
652 }
653
654 /// Tampering the probe field is detectable via the hash chain.
655 #[test]
656 fn tampering_probe_field_is_detectable() {
657 let mut t0 = sample_trace(GENESIS_HASH, 20);
658 t0.probe = Some(ProbeSignal {
659 k: 5,
660 gate_pass_count: 5,
661 regime: ProbeRegime::ConfidentPass,
662 probe_cost_usd: 0.0005,
663 });
664 let t1 = sample_trace(&t0.hash().unwrap(), 21);
665 let mut chain = [t0, t1];
666 // Tamper the probe field.
667 chain[0].probe.as_mut().unwrap().gate_pass_count = 0;
668 assert!(
669 verify_chain(&chain, GENESIS_HASH).is_err(),
670 "tampered probe must break the chain"
671 );
672 }
673}