Skip to main content

tsift_quality/
token_gate.rs

1//! Cross-surface token/performance gate (#tokegate).
2//!
3//! Records and gates token efficiency across tsift agent-facing surfaces:
4//! `context-pack`, `session-review --next-context`, `graph-db evidence`,
5//! `conflict-matrix`, and `dispatch-trace`.
6//!
7//! For each surface the gate tracks: prompt tokens, envelope bytes,
8//! runtime, cache-hit rate, raw-read avoidance, and useful-hit density.
9//! A regression on any required metric across any surface blocks the gate.
10//!
11//! Spec: see specs/graph.md § "Cross-Surface Token Gate".
12
13use anyhow::{Context, Result, bail};
14use serde::Serialize;
15use serde_json::Value;
16use std::collections::BTreeMap;
17
18pub const MIN_TOKEN_GATE_SAMPLES: usize = 3;
19
20pub const TOKEN_GATE_SURFACES: [&str; 5] = [
21    "context_pack",
22    "session_review_next_context",
23    "graph_db_evidence",
24    "conflict_matrix",
25    "dispatch_trace",
26];
27
28pub fn surface_display_name(surface: &str) -> &'static str {
29    match surface {
30        "context_pack" => "context-pack",
31        "session_review_next_context" => "session-review --next-context",
32        "graph_db_evidence" => "graph-db evidence",
33        "conflict_matrix" => "conflict-matrix",
34        "dispatch_trace" => "dispatch-trace",
35        _ => "unknown",
36    }
37}
38
39pub const REQUIRED_TOKEN_METRICS: [&str; 6] = [
40    "prompt_tokens",
41    "envelope_bytes",
42    "runtime_micros",
43    "cache_hit_rate_percent",
44    "raw_read_avoidance",
45    "useful_hit_density",
46];
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "snake_case")]
50pub enum TokenMetricDirection {
51    LowerIsBetter,
52    HigherIsBetter,
53}
54
55pub fn metric_direction(metric: &str) -> TokenMetricDirection {
56    match metric {
57        "prompt_tokens" | "envelope_bytes" | "runtime_micros" => {
58            TokenMetricDirection::LowerIsBetter
59        }
60        "cache_hit_rate_percent" | "raw_read_avoidance" | "useful_hit_density" => {
61            TokenMetricDirection::HigherIsBetter
62        }
63        _ => TokenMetricDirection::LowerIsBetter,
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize)]
68pub struct TokenGateSample {
69    pub label: String,
70    pub id: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub timestamp: Option<String>,
73    pub surface: String,
74    pub metrics: BTreeMap<String, f64>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "snake_case")]
79pub enum TokenSurfaceVerdict {
80    Pass,
81    Regressed,
82    InsufficientSamples,
83    Missing,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize)]
87pub struct TokenSurfaceMetricEvaluation {
88    pub metric: String,
89    pub direction: TokenMetricDirection,
90    pub baseline_median: Option<f64>,
91    pub candidate_median: Option<f64>,
92    pub passed: bool,
93    pub diagnostic: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize)]
97pub struct TokenSurfaceEvaluation {
98    pub surface: String,
99    pub display_name: String,
100    pub sample_count: usize,
101    pub verdict: TokenSurfaceVerdict,
102    pub metric_evaluations: Vec<TokenSurfaceMetricEvaluation>,
103    pub diagnostics: Vec<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "snake_case")]
108pub enum TokenGateDecision {
109    Pass,
110    Block,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize)]
114pub struct TokenGateReport {
115    pub min_samples: usize,
116    pub allowed_regression_percent: f64,
117    pub surface_evaluations: Vec<TokenSurfaceEvaluation>,
118    pub decision: TokenGateDecision,
119    pub diagnostics: Vec<String>,
120}
121
122pub fn parse_token_history(raw: &str) -> Result<Vec<TokenGateSample>> {
123    let value: Value =
124        serde_json::from_str(raw).context("token_gate: failed to parse history JSON")?;
125    let entries = match value {
126        Value::Object(mut obj) => match obj.remove("entries") {
127            Some(Value::Array(arr)) => arr,
128            Some(other) => bail!(
129                "token_gate: history `entries` field must be an array, got {}",
130                value_type_name(&other)
131            ),
132            None => bail!("token_gate: history JSON object missing `entries` array"),
133        },
134        Value::Array(arr) => arr,
135        other => bail!(
136            "token_gate: history root must be object or array, got {}",
137            value_type_name(&other)
138        ),
139    };
140
141    let mut samples = Vec::with_capacity(entries.len());
142    for (idx, entry) in entries.into_iter().enumerate() {
143        let obj = entry
144            .as_object()
145            .with_context(|| format!("token_gate: entry #{idx} must be a JSON object"))?;
146        let label = obj
147            .get("label")
148            .and_then(|v| v.as_str())
149            .with_context(|| format!("token_gate: entry #{idx} missing string `label`"))?
150            .to_string();
151        let id = obj
152            .get("id")
153            .and_then(|v| v.as_str())
154            .with_context(|| format!("token_gate: entry #{idx} missing string `id`"))?
155            .to_string();
156        let timestamp = obj
157            .get("timestamp")
158            .and_then(|v| v.as_str())
159            .map(|s| s.to_string());
160        let surface = obj
161            .get("surface")
162            .and_then(|v| v.as_str())
163            .with_context(|| format!("token_gate: entry #{idx} missing string `surface`"))?
164            .to_string();
165        let metrics_value = obj
166            .get("metrics")
167            .with_context(|| format!("token_gate: entry #{idx} missing `metrics` map"))?;
168        let metrics_obj = metrics_value
169            .as_object()
170            .with_context(|| format!("token_gate: entry #{idx} `metrics` must be an object"))?;
171        let mut metrics = BTreeMap::new();
172        for (key, val) in metrics_obj {
173            if let Some(n) = val.as_f64() {
174                metrics.insert(key.clone(), n);
175            }
176        }
177
178        samples.push(TokenGateSample {
179            label,
180            id,
181            timestamp,
182            surface,
183            metrics,
184        });
185    }
186    Ok(samples)
187}
188
189fn value_type_name(v: &Value) -> &'static str {
190    match v {
191        Value::Null => "null",
192        Value::Bool(_) => "bool",
193        Value::Number(_) => "number",
194        Value::String(_) => "string",
195        Value::Array(_) => "array",
196        Value::Object(_) => "object",
197    }
198}
199
200fn median_f64(values: &[f64]) -> Option<f64> {
201    if values.is_empty() {
202        return None;
203    }
204    let mut sorted: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
205    if sorted.is_empty() {
206        return None;
207    }
208    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
209    let n = sorted.len();
210    if n.is_multiple_of(2) {
211        Some((sorted[n / 2 - 1] + sorted[n / 2]) / 2.0)
212    } else {
213        Some(sorted[n / 2])
214    }
215}
216
217pub fn evaluate_token_gate(
218    history: &[TokenGateSample],
219    allowed_regression_percent: f64,
220) -> TokenGateReport {
221    let mut surface_evaluations = Vec::with_capacity(TOKEN_GATE_SURFACES.len());
222    let mut top_diagnostics = Vec::new();
223    let mut any_block = false;
224
225    for surface in TOKEN_GATE_SURFACES {
226        let display = surface_display_name(surface).to_string();
227        let surface_samples: Vec<&TokenGateSample> =
228            history.iter().filter(|s| s.surface == surface).collect();
229        let sample_count = surface_samples.len();
230
231        if sample_count == 0 {
232            surface_evaluations.push(TokenSurfaceEvaluation {
233                surface: surface.to_string(),
234                display_name: display.clone(),
235                sample_count: 0,
236                verdict: TokenSurfaceVerdict::Missing,
237                metric_evaluations: Vec::new(),
238                diagnostics: vec![format!(
239                    "surface `{display}` has no samples in history; gate blocks until at least {MIN_TOKEN_GATE_SAMPLES} samples are recorded"
240                )],
241            });
242            any_block = true;
243            top_diagnostics.push(format!("`{display}`: missing"));
244            continue;
245        }
246
247        if sample_count < MIN_TOKEN_GATE_SAMPLES {
248            surface_evaluations.push(TokenSurfaceEvaluation {
249                surface: surface.to_string(),
250                display_name: display.clone(),
251                sample_count,
252                verdict: TokenSurfaceVerdict::InsufficientSamples,
253                metric_evaluations: Vec::new(),
254                diagnostics: vec![format!(
255                    "surface `{display}` has {sample_count} sample(s); gate requires {MIN_TOKEN_GATE_SAMPLES}"
256                )],
257            });
258            any_block = true;
259            top_diagnostics.push(format!(
260                "`{display}`: only {sample_count}/{MIN_TOKEN_GATE_SAMPLES} samples"
261            ));
262            continue;
263        }
264
265        let mut metric_evaluations = Vec::with_capacity(REQUIRED_TOKEN_METRICS.len());
266        let mut surface_pass = true;
267
268        for metric_name in REQUIRED_TOKEN_METRICS {
269            let values: Vec<f64> = surface_samples
270                .iter()
271                .filter_map(|s| s.metrics.get(metric_name).copied())
272                .collect();
273
274            let direction = metric_direction(metric_name);
275            let median = if values.len() >= MIN_TOKEN_GATE_SAMPLES {
276                median_f64(&values)
277            } else {
278                None
279            };
280
281            let passed = median.is_some_and(|m| match direction {
282                TokenMetricDirection::LowerIsBetter => m > 0.0,
283                TokenMetricDirection::HigherIsBetter => m > 0.0,
284            });
285
286            let diagnostic = match (median, passed) {
287                (Some(m), true) => {
288                    let dir_label = match direction {
289                        TokenMetricDirection::LowerIsBetter => "lower is better",
290                        TokenMetricDirection::HigherIsBetter => "higher is better",
291                    };
292                    format!("`{metric_name}` median {m:.2} ({dir_label}) — present")
293                }
294                (Some(m), false) => {
295                    format!("`{metric_name}` median {m:.2} is zero or negative — no signal")
296                }
297                (None, _) => {
298                    format!(
299                        "`{metric_name}` has fewer than {MIN_TOKEN_GATE_SAMPLES} values across {sample_count} samples"
300                    )
301                }
302            };
303
304            if !passed {
305                surface_pass = false;
306            }
307
308            metric_evaluations.push(TokenSurfaceMetricEvaluation {
309                metric: metric_name.to_string(),
310                direction,
311                baseline_median: None,
312                candidate_median: median,
313                passed,
314                diagnostic,
315            });
316        }
317
318        if !surface_pass {
319            any_block = true;
320            top_diagnostics.push(format!("`{display}`: metric regression"));
321        }
322
323        surface_evaluations.push(TokenSurfaceEvaluation {
324            surface: surface.to_string(),
325            display_name: display,
326            sample_count,
327            verdict: if surface_pass {
328                TokenSurfaceVerdict::Pass
329            } else {
330                TokenSurfaceVerdict::Regressed
331            },
332            metric_evaluations,
333            diagnostics: Vec::new(),
334        });
335    }
336
337    TokenGateReport {
338        min_samples: MIN_TOKEN_GATE_SAMPLES,
339        allowed_regression_percent,
340        surface_evaluations,
341        decision: if any_block {
342            TokenGateDecision::Block
343        } else {
344            TokenGateDecision::Pass
345        },
346        diagnostics: top_diagnostics,
347    }
348}
349
350pub fn evaluate_token_regression(
351    baseline: &[TokenGateSample],
352    candidate: &[TokenGateSample],
353    allowed_regression_percent: f64,
354) -> TokenGateReport {
355    let mut surface_evaluations = Vec::with_capacity(TOKEN_GATE_SURFACES.len());
356    let mut top_diagnostics = Vec::new();
357    let mut any_block = false;
358
359    for surface in TOKEN_GATE_SURFACES {
360        let display = surface_display_name(surface).to_string();
361        let baseline_samples: Vec<&TokenGateSample> =
362            baseline.iter().filter(|s| s.surface == surface).collect();
363        let candidate_samples: Vec<&TokenGateSample> =
364            candidate.iter().filter(|s| s.surface == surface).collect();
365
366        if baseline_samples.is_empty() && candidate_samples.is_empty() {
367            surface_evaluations.push(TokenSurfaceEvaluation {
368                surface: surface.to_string(),
369                display_name: display.clone(),
370                sample_count: 0,
371                verdict: TokenSurfaceVerdict::Missing,
372                metric_evaluations: Vec::new(),
373                diagnostics: vec![format!(
374                    "surface `{display}` has no baseline or candidate samples"
375                )],
376            });
377            any_block = true;
378            top_diagnostics.push(format!("`{display}`: missing"));
379            continue;
380        }
381
382        if baseline_samples.len() < MIN_TOKEN_GATE_SAMPLES
383            || candidate_samples.len() < MIN_TOKEN_GATE_SAMPLES
384        {
385            let b_count = baseline_samples.len();
386            let c_count = candidate_samples.len();
387            surface_evaluations.push(TokenSurfaceEvaluation {
388                surface: surface.to_string(),
389                display_name: display.clone(),
390                sample_count: b_count.max(c_count),
391                verdict: TokenSurfaceVerdict::InsufficientSamples,
392                metric_evaluations: Vec::new(),
393                diagnostics: vec![format!(
394                    "surface `{display}` baseline={b_count} candidate={c_count}; gate requires {MIN_TOKEN_GATE_SAMPLES} each"
395                )],
396            });
397            any_block = true;
398            top_diagnostics.push(format!(
399                "`{display}`: insufficient samples (baseline={b_count}, candidate={c_count})"
400            ));
401            continue;
402        }
403
404        let mut metric_evaluations = Vec::with_capacity(REQUIRED_TOKEN_METRICS.len());
405        let mut diagnostics = Vec::new();
406        let mut surface_pass = true;
407        let regression_multiplier = allowed_regression_percent / 100.0;
408
409        for metric_name in REQUIRED_TOKEN_METRICS {
410            let baseline_values: Vec<f64> = baseline_samples
411                .iter()
412                .filter_map(|s| s.metrics.get(metric_name).copied())
413                .collect();
414            let candidate_values: Vec<f64> = candidate_samples
415                .iter()
416                .filter_map(|s| s.metrics.get(metric_name).copied())
417                .collect();
418            let direction = metric_direction(metric_name);
419            let baseline_median = if baseline_values.len() >= MIN_TOKEN_GATE_SAMPLES {
420                median_f64(&baseline_values)
421            } else {
422                None
423            };
424            let candidate_median = if candidate_values.len() >= MIN_TOKEN_GATE_SAMPLES {
425                median_f64(&candidate_values)
426            } else {
427                None
428            };
429
430            let (passed, diagnostic) = match (baseline_median, candidate_median) {
431                (Some(base), Some(cand)) => {
432                    let ok = match direction {
433                        TokenMetricDirection::LowerIsBetter => {
434                            cand <= base * (1.0 + regression_multiplier)
435                        }
436                        TokenMetricDirection::HigherIsBetter => {
437                            cand >= base * (1.0 - regression_multiplier)
438                        }
439                    };
440                    let diagnostic = if ok {
441                        format!(
442                            "`{metric_name}`: candidate {cand:.2} vs baseline {base:.2} ({}) — within budget",
443                            match direction {
444                                TokenMetricDirection::LowerIsBetter => "lower is better",
445                                TokenMetricDirection::HigherIsBetter => "higher is better",
446                            }
447                        )
448                    } else {
449                        format!(
450                            "`{metric_name}` REGRESSES: candidate {cand:.2} vs baseline {base:.2} ({})",
451                            match direction {
452                                TokenMetricDirection::LowerIsBetter => "lower is better",
453                                TokenMetricDirection::HigherIsBetter => "higher is better",
454                            }
455                        )
456                    };
457                    (ok, diagnostic)
458                }
459                (Some(_), None) => (
460                    false,
461                    format!(
462                        "`{metric_name}`: candidate has fewer than {MIN_TOKEN_GATE_SAMPLES} values"
463                    ),
464                ),
465                (None, Some(_)) => (
466                    false,
467                    format!(
468                        "`{metric_name}`: baseline has fewer than {MIN_TOKEN_GATE_SAMPLES} values"
469                    ),
470                ),
471                (None, None) => (
472                    false,
473                    format!(
474                        "`{metric_name}`: neither baseline nor candidate has {MIN_TOKEN_GATE_SAMPLES} values"
475                    ),
476                ),
477            };
478
479            if !passed {
480                surface_pass = false;
481            }
482
483            diagnostics.push(diagnostic.clone());
484            metric_evaluations.push(TokenSurfaceMetricEvaluation {
485                metric: metric_name.to_string(),
486                direction,
487                baseline_median,
488                candidate_median,
489                passed,
490                diagnostic,
491            });
492        }
493
494        if !surface_pass {
495            any_block = true;
496            top_diagnostics.push(format!("`{display}`: regression detected"));
497        }
498
499        surface_evaluations.push(TokenSurfaceEvaluation {
500            surface: surface.to_string(),
501            display_name: display,
502            sample_count: candidate_samples.len(),
503            verdict: if surface_pass {
504                TokenSurfaceVerdict::Pass
505            } else {
506                TokenSurfaceVerdict::Regressed
507            },
508            metric_evaluations,
509            diagnostics,
510        });
511    }
512
513    TokenGateReport {
514        min_samples: MIN_TOKEN_GATE_SAMPLES,
515        allowed_regression_percent,
516        surface_evaluations,
517        decision: if any_block {
518            TokenGateDecision::Block
519        } else {
520            TokenGateDecision::Pass
521        },
522        diagnostics: top_diagnostics,
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[allow(clippy::too_many_arguments)]
531    fn synth_token_sample(
532        id: &str,
533        surface: &str,
534        prompt_tokens: f64,
535        envelope_bytes: f64,
536        runtime_micros: f64,
537        cache_hit_rate: f64,
538        raw_read_avoidance: f64,
539        useful_hit_density: f64,
540    ) -> Value {
541        let mut metrics = serde_json::Map::new();
542        metrics.insert("prompt_tokens".into(), Value::from(prompt_tokens));
543        metrics.insert("envelope_bytes".into(), Value::from(envelope_bytes));
544        metrics.insert("runtime_micros".into(), Value::from(runtime_micros));
545        metrics.insert("cache_hit_rate_percent".into(), Value::from(cache_hit_rate));
546        metrics.insert("raw_read_avoidance".into(), Value::from(raw_read_avoidance));
547        metrics.insert("useful_hit_density".into(), Value::from(useful_hit_density));
548        let mut entry = serde_json::Map::new();
549        entry.insert(
550            "label".into(),
551            Value::from(format!("synth {surface} sample")),
552        );
553        entry.insert("id".into(), Value::from(id.to_string()));
554        entry.insert("timestamp".into(), Value::from("2026-06-02T00:00:00Z"));
555        entry.insert("surface".into(), Value::from(surface.to_string()));
556        entry.insert("metrics".into(), Value::Object(metrics));
557        Value::Object(entry)
558    }
559
560    fn build_token_history(samples: Vec<Value>) -> String {
561        let mut root = serde_json::Map::new();
562        root.insert("entries".into(), Value::Array(samples));
563        Value::Object(root).to_string()
564    }
565
566    fn full_token_history_three_samples_each() -> String {
567        let mut entries = Vec::new();
568        for surface in TOKEN_GATE_SURFACES {
569            for i in 1..=3 {
570                entries.push(synth_token_sample(
571                    &format!("synth-{surface}-2026-06-02-sample-{i}"),
572                    surface,
573                    500.0,
574                    2048.0,
575                    150_000.0,
576                    85.0,
577                    12.0,
578                    0.72,
579                ));
580            }
581        }
582        build_token_history(entries)
583    }
584
585    #[test]
586    fn parse_token_history_extracts_surfaces_and_metrics() {
587        let raw = synth_token_sample(
588            "test-cp-2026-06-02-sample-1",
589            "context_pack",
590            100.0,
591            512.0,
592            50_000.0,
593            90.0,
594            8.0,
595            0.85,
596        );
597        let history_raw = build_token_history(vec![raw]);
598        let samples = parse_token_history(&history_raw).unwrap();
599        assert_eq!(samples.len(), 1);
600        assert_eq!(samples[0].surface, "context_pack");
601        assert_eq!(samples[0].metrics.len(), 6);
602    }
603
604    #[test]
605    fn token_gate_passes_when_all_surfaces_have_samples_with_signal() {
606        let raw = full_token_history_three_samples_each();
607        let history = parse_token_history(&raw).unwrap();
608        let report = evaluate_token_gate(&history, 10.0);
609        assert_eq!(report.decision, TokenGateDecision::Pass, "{report:?}");
610        assert!(
611            report
612                .surface_evaluations
613                .iter()
614                .all(|s| s.verdict == TokenSurfaceVerdict::Pass)
615        );
616    }
617
618    #[test]
619    fn token_gate_blocks_when_surface_is_missing() {
620        let mut entries = Vec::new();
621        for surface in &TOKEN_GATE_SURFACES[..4] {
622            for i in 1..=3 {
623                entries.push(synth_token_sample(
624                    &format!("synth-{surface}-2026-06-02-sample-{i}"),
625                    surface,
626                    500.0,
627                    2048.0,
628                    150_000.0,
629                    85.0,
630                    12.0,
631                    0.72,
632                ));
633            }
634        }
635        let raw = build_token_history(entries);
636        let history = parse_token_history(&raw).unwrap();
637        let report = evaluate_token_gate(&history, 10.0);
638        assert_eq!(report.decision, TokenGateDecision::Block);
639        let missing = report
640            .surface_evaluations
641            .iter()
642            .filter(|s| s.verdict == TokenSurfaceVerdict::Missing)
643            .count();
644        assert_eq!(missing, 1);
645    }
646
647    #[test]
648    fn token_gate_blocks_when_insufficient_samples() {
649        let mut entries = Vec::new();
650        for surface in TOKEN_GATE_SURFACES {
651            for i in 1..=2 {
652                entries.push(synth_token_sample(
653                    &format!("synth-{surface}-2026-06-02-sample-{i}"),
654                    surface,
655                    500.0,
656                    2048.0,
657                    150_000.0,
658                    85.0,
659                    12.0,
660                    0.72,
661                ));
662            }
663        }
664        let raw = build_token_history(entries);
665        let history = parse_token_history(&raw).unwrap();
666        let report = evaluate_token_gate(&history, 10.0);
667        assert_eq!(report.decision, TokenGateDecision::Block);
668        assert!(
669            report
670                .surface_evaluations
671                .iter()
672                .all(|s| s.verdict == TokenSurfaceVerdict::InsufficientSamples)
673        );
674    }
675
676    #[test]
677    fn token_regression_passes_when_candidate_matches_baseline() {
678        let raw = full_token_history_three_samples_each();
679        let baseline = parse_token_history(&raw).unwrap();
680        let candidate = baseline.clone();
681        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
682        assert_eq!(report.decision, TokenGateDecision::Pass, "{report:?}");
683    }
684
685    #[test]
686    fn token_regression_blocks_when_lower_is_better_metric_regresses() {
687        let mut baseline_entries = Vec::new();
688        let mut candidate_entries = Vec::new();
689        for surface in TOKEN_GATE_SURFACES {
690            for i in 1..=3 {
691                baseline_entries.push(synth_token_sample(
692                    &format!("base-{surface}-sample-{i}"),
693                    surface,
694                    500.0,
695                    2048.0,
696                    150_000.0,
697                    85.0,
698                    12.0,
699                    0.72,
700                ));
701                candidate_entries.push(synth_token_sample(
702                    &format!("cand-{surface}-sample-{i}"),
703                    surface,
704                    5000.0,
705                    2048.0,
706                    150_000.0,
707                    85.0,
708                    12.0,
709                    0.72,
710                ));
711            }
712        }
713        let baseline = parse_token_history(&build_token_history(baseline_entries)).unwrap();
714        let candidate = parse_token_history(&build_token_history(candidate_entries)).unwrap();
715        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
716        assert_eq!(report.decision, TokenGateDecision::Block);
717        assert!(report.surface_evaluations.iter().all(|s| {
718            s.metric_evaluations
719                .iter()
720                .find(|m| m.metric == "prompt_tokens")
721                .is_some_and(|m| !m.passed)
722        }));
723    }
724
725    #[test]
726    fn token_regression_blocks_when_higher_is_better_metric_regresses() {
727        let mut baseline_entries = Vec::new();
728        let mut candidate_entries = Vec::new();
729        for surface in TOKEN_GATE_SURFACES {
730            for i in 1..=3 {
731                baseline_entries.push(synth_token_sample(
732                    &format!("base-{surface}-sample-{i}"),
733                    surface,
734                    500.0,
735                    2048.0,
736                    150_000.0,
737                    85.0,
738                    12.0,
739                    0.72,
740                ));
741                candidate_entries.push(synth_token_sample(
742                    &format!("cand-{surface}-sample-{i}"),
743                    surface,
744                    500.0,
745                    2048.0,
746                    150_000.0,
747                    10.0,
748                    12.0,
749                    0.72,
750                ));
751            }
752        }
753        let baseline = parse_token_history(&build_token_history(baseline_entries)).unwrap();
754        let candidate = parse_token_history(&build_token_history(candidate_entries)).unwrap();
755        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
756        assert_eq!(report.decision, TokenGateDecision::Block);
757    }
758
759    #[test]
760    fn token_regression_blocks_when_both_missing() {
761        let baseline = parse_token_history(&build_token_history(vec![])).unwrap();
762        let candidate = parse_token_history(&build_token_history(vec![])).unwrap();
763        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
764        assert_eq!(report.decision, TokenGateDecision::Block);
765        assert!(
766            report
767                .surface_evaluations
768                .iter()
769                .all(|s| s.verdict == TokenSurfaceVerdict::Missing)
770        );
771    }
772}