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> = history
228            .iter()
229            .filter(|s| s.surface == surface)
230            .collect();
231        let sample_count = surface_samples.len();
232
233        if sample_count == 0 {
234            surface_evaluations.push(TokenSurfaceEvaluation {
235                surface: surface.to_string(),
236                display_name: display.clone(),
237                sample_count: 0,
238                verdict: TokenSurfaceVerdict::Missing,
239                metric_evaluations: Vec::new(),
240                diagnostics: vec![format!(
241                    "surface `{display}` has no samples in history; gate blocks until at least {MIN_TOKEN_GATE_SAMPLES} samples are recorded"
242                )],
243            });
244            any_block = true;
245            top_diagnostics.push(format!("`{display}`: missing"));
246            continue;
247        }
248
249        if sample_count < MIN_TOKEN_GATE_SAMPLES {
250            surface_evaluations.push(TokenSurfaceEvaluation {
251                surface: surface.to_string(),
252                display_name: display.clone(),
253                sample_count,
254                verdict: TokenSurfaceVerdict::InsufficientSamples,
255                metric_evaluations: Vec::new(),
256                diagnostics: vec![format!(
257                    "surface `{display}` has {sample_count} sample(s); gate requires {MIN_TOKEN_GATE_SAMPLES}"
258                )],
259            });
260            any_block = true;
261            top_diagnostics.push(format!(
262                "`{display}`: only {sample_count}/{MIN_TOKEN_GATE_SAMPLES} samples"
263            ));
264            continue;
265        }
266
267        let mut metric_evaluations = Vec::with_capacity(REQUIRED_TOKEN_METRICS.len());
268        let mut surface_pass = true;
269
270        for metric_name in REQUIRED_TOKEN_METRICS {
271            let values: Vec<f64> = surface_samples
272                .iter()
273                .filter_map(|s| s.metrics.get(metric_name).copied())
274                .collect();
275
276            let direction = metric_direction(metric_name);
277            let median = if values.len() >= MIN_TOKEN_GATE_SAMPLES {
278                median_f64(&values)
279            } else {
280                None
281            };
282
283            let passed = median.is_some_and(|m| match direction {
284                TokenMetricDirection::LowerIsBetter => m > 0.0,
285                TokenMetricDirection::HigherIsBetter => m > 0.0,
286            });
287
288            let diagnostic = match (median, passed) {
289                (Some(m), true) => {
290                    let dir_label = match direction {
291                        TokenMetricDirection::LowerIsBetter => "lower is better",
292                        TokenMetricDirection::HigherIsBetter => "higher is better",
293                    };
294                    format!(
295                        "`{metric_name}` median {m:.2} ({dir_label}) — present"
296                    )
297                }
298                (Some(m), false) => {
299                    format!(
300                        "`{metric_name}` median {m:.2} is zero or negative — no signal"
301                    )
302                }
303                (None, _) => {
304                    format!(
305                        "`{metric_name}` has fewer than {MIN_TOKEN_GATE_SAMPLES} values across {sample_count} samples"
306                    )
307                }
308            };
309
310            if !passed {
311                surface_pass = false;
312            }
313
314            metric_evaluations.push(TokenSurfaceMetricEvaluation {
315                metric: metric_name.to_string(),
316                direction,
317                baseline_median: None,
318                candidate_median: median,
319                passed,
320                diagnostic,
321            });
322        }
323
324        if !surface_pass {
325            any_block = true;
326            top_diagnostics.push(format!("`{display}`: metric regression"));
327        }
328
329        surface_evaluations.push(TokenSurfaceEvaluation {
330            surface: surface.to_string(),
331            display_name: display,
332            sample_count,
333            verdict: if surface_pass {
334                TokenSurfaceVerdict::Pass
335            } else {
336                TokenSurfaceVerdict::Regressed
337            },
338            metric_evaluations,
339            diagnostics: Vec::new(),
340        });
341    }
342
343    TokenGateReport {
344        min_samples: MIN_TOKEN_GATE_SAMPLES,
345        allowed_regression_percent,
346        surface_evaluations,
347        decision: if any_block {
348            TokenGateDecision::Block
349        } else {
350            TokenGateDecision::Pass
351        },
352        diagnostics: top_diagnostics,
353    }
354}
355
356pub fn evaluate_token_regression(
357    baseline: &[TokenGateSample],
358    candidate: &[TokenGateSample],
359    allowed_regression_percent: f64,
360) -> TokenGateReport {
361    let mut surface_evaluations = Vec::with_capacity(TOKEN_GATE_SURFACES.len());
362    let mut top_diagnostics = Vec::new();
363    let mut any_block = false;
364
365    for surface in TOKEN_GATE_SURFACES {
366        let display = surface_display_name(surface).to_string();
367        let baseline_samples: Vec<&TokenGateSample> = baseline
368            .iter()
369            .filter(|s| s.surface == surface)
370            .collect();
371        let candidate_samples: Vec<&TokenGateSample> = candidate
372            .iter()
373            .filter(|s| s.surface == surface)
374            .collect();
375
376        if baseline_samples.is_empty() && candidate_samples.is_empty() {
377            surface_evaluations.push(TokenSurfaceEvaluation {
378                surface: surface.to_string(),
379                display_name: display.clone(),
380                sample_count: 0,
381                verdict: TokenSurfaceVerdict::Missing,
382                metric_evaluations: Vec::new(),
383                diagnostics: vec![format!(
384                    "surface `{display}` has no baseline or candidate samples"
385                )],
386            });
387            any_block = true;
388            top_diagnostics.push(format!("`{display}`: missing"));
389            continue;
390        }
391
392        if baseline_samples.len() < MIN_TOKEN_GATE_SAMPLES
393            || candidate_samples.len() < MIN_TOKEN_GATE_SAMPLES
394        {
395            let b_count = baseline_samples.len();
396            let c_count = candidate_samples.len();
397            surface_evaluations.push(TokenSurfaceEvaluation {
398                surface: surface.to_string(),
399                display_name: display.clone(),
400                sample_count: b_count.max(c_count),
401                verdict: TokenSurfaceVerdict::InsufficientSamples,
402                metric_evaluations: Vec::new(),
403                diagnostics: vec![format!(
404                    "surface `{display}` baseline={b_count} candidate={c_count}; gate requires {MIN_TOKEN_GATE_SAMPLES} each"
405                )],
406            });
407            any_block = true;
408            top_diagnostics.push(format!(
409                "`{display}`: insufficient samples (baseline={b_count}, candidate={c_count})"
410            ));
411            continue;
412        }
413
414        let mut metric_evaluations = Vec::with_capacity(REQUIRED_TOKEN_METRICS.len());
415        let mut diagnostics = Vec::new();
416        let mut surface_pass = true;
417        let regression_multiplier = allowed_regression_percent / 100.0;
418
419        for metric_name in REQUIRED_TOKEN_METRICS {
420            let baseline_values: Vec<f64> = baseline_samples
421                .iter()
422                .filter_map(|s| s.metrics.get(metric_name).copied())
423                .collect();
424            let candidate_values: Vec<f64> = candidate_samples
425                .iter()
426                .filter_map(|s| s.metrics.get(metric_name).copied())
427                .collect();
428            let direction = metric_direction(metric_name);
429            let baseline_median = if baseline_values.len() >= MIN_TOKEN_GATE_SAMPLES {
430                median_f64(&baseline_values)
431            } else {
432                None
433            };
434            let candidate_median = if candidate_values.len() >= MIN_TOKEN_GATE_SAMPLES {
435                median_f64(&candidate_values)
436            } else {
437                None
438            };
439
440            let (passed, diagnostic) = match (baseline_median, candidate_median) {
441                (Some(base), Some(cand)) => {
442                    let ok = match direction {
443                        TokenMetricDirection::LowerIsBetter => {
444                            cand <= base * (1.0 + regression_multiplier)
445                        }
446                        TokenMetricDirection::HigherIsBetter => {
447                            cand >= base * (1.0 - regression_multiplier)
448                        }
449                    };
450                    let diagnostic = if ok {
451                        format!(
452                            "`{metric_name}`: candidate {cand:.2} vs baseline {base:.2} ({}) — within budget",
453                            match direction {
454                                TokenMetricDirection::LowerIsBetter => "lower is better",
455                                TokenMetricDirection::HigherIsBetter => "higher is better",
456                            }
457                        )
458                    } else {
459                        format!(
460                            "`{metric_name}` REGRESSES: candidate {cand:.2} vs baseline {base:.2} ({})",
461                            match direction {
462                                TokenMetricDirection::LowerIsBetter => "lower is better",
463                                TokenMetricDirection::HigherIsBetter => "higher is better",
464                            }
465                        )
466                    };
467                    (ok, diagnostic)
468                }
469                (Some(_), None) => (
470                    false,
471                    format!(
472                        "`{metric_name}`: candidate has fewer than {MIN_TOKEN_GATE_SAMPLES} values"
473                    ),
474                ),
475                (None, Some(_)) => (
476                    false,
477                    format!(
478                        "`{metric_name}`: baseline has fewer than {MIN_TOKEN_GATE_SAMPLES} values"
479                    ),
480                ),
481                (None, None) => (
482                    false,
483                    format!(
484                        "`{metric_name}`: neither baseline nor candidate has {MIN_TOKEN_GATE_SAMPLES} values"
485                    ),
486                ),
487            };
488
489            if !passed {
490                surface_pass = false;
491            }
492
493            diagnostics.push(diagnostic.clone());
494            metric_evaluations.push(TokenSurfaceMetricEvaluation {
495                metric: metric_name.to_string(),
496                direction,
497                baseline_median,
498                candidate_median,
499                passed,
500                diagnostic,
501            });
502        }
503
504        if !surface_pass {
505            any_block = true;
506            top_diagnostics.push(format!("`{display}`: regression detected"));
507        }
508
509        surface_evaluations.push(TokenSurfaceEvaluation {
510            surface: surface.to_string(),
511            display_name: display,
512            sample_count: candidate_samples.len(),
513            verdict: if surface_pass {
514                TokenSurfaceVerdict::Pass
515            } else {
516                TokenSurfaceVerdict::Regressed
517            },
518            metric_evaluations,
519            diagnostics,
520        });
521    }
522
523    TokenGateReport {
524        min_samples: MIN_TOKEN_GATE_SAMPLES,
525        allowed_regression_percent,
526        surface_evaluations,
527        decision: if any_block {
528            TokenGateDecision::Block
529        } else {
530            TokenGateDecision::Pass
531        },
532        diagnostics: top_diagnostics,
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[allow(clippy::too_many_arguments)]
541    fn synth_token_sample(
542        id: &str,
543        surface: &str,
544        prompt_tokens: f64,
545        envelope_bytes: f64,
546        runtime_micros: f64,
547        cache_hit_rate: f64,
548        raw_read_avoidance: f64,
549        useful_hit_density: f64,
550    ) -> Value {
551        let mut metrics = serde_json::Map::new();
552        metrics.insert("prompt_tokens".into(), Value::from(prompt_tokens));
553        metrics.insert("envelope_bytes".into(), Value::from(envelope_bytes));
554        metrics.insert("runtime_micros".into(), Value::from(runtime_micros));
555        metrics.insert("cache_hit_rate_percent".into(), Value::from(cache_hit_rate));
556        metrics.insert("raw_read_avoidance".into(), Value::from(raw_read_avoidance));
557        metrics.insert("useful_hit_density".into(), Value::from(useful_hit_density));
558        let mut entry = serde_json::Map::new();
559        entry.insert("label".into(), Value::from(format!("synth {surface} sample")));
560        entry.insert("id".into(), Value::from(id.to_string()));
561        entry.insert("timestamp".into(), Value::from("2026-06-02T00:00:00Z"));
562        entry.insert("surface".into(), Value::from(surface.to_string()));
563        entry.insert("metrics".into(), Value::Object(metrics));
564        Value::Object(entry)
565    }
566
567    fn build_token_history(samples: Vec<Value>) -> String {
568        let mut root = serde_json::Map::new();
569        root.insert("entries".into(), Value::Array(samples));
570        Value::Object(root).to_string()
571    }
572
573    fn full_token_history_three_samples_each() -> String {
574        let mut entries = Vec::new();
575        for surface in TOKEN_GATE_SURFACES {
576            for i in 1..=3 {
577                entries.push(synth_token_sample(
578                    &format!("synth-{surface}-2026-06-02-sample-{i}"),
579                    surface,
580                    500.0,
581                    2048.0,
582                    150_000.0,
583                    85.0,
584                    12.0,
585                    0.72,
586                ));
587            }
588        }
589        build_token_history(entries)
590    }
591
592    #[test]
593    fn parse_token_history_extracts_surfaces_and_metrics() {
594        let raw = synth_token_sample(
595            "test-cp-2026-06-02-sample-1",
596            "context_pack",
597            100.0,
598            512.0,
599            50_000.0,
600            90.0,
601            8.0,
602            0.85,
603        );
604        let history_raw = build_token_history(vec![raw]);
605        let samples = parse_token_history(&history_raw).unwrap();
606        assert_eq!(samples.len(), 1);
607        assert_eq!(samples[0].surface, "context_pack");
608        assert_eq!(samples[0].metrics.len(), 6);
609    }
610
611    #[test]
612    fn token_gate_passes_when_all_surfaces_have_samples_with_signal() {
613        let raw = full_token_history_three_samples_each();
614        let history = parse_token_history(&raw).unwrap();
615        let report = evaluate_token_gate(&history, 10.0);
616        assert_eq!(report.decision, TokenGateDecision::Pass, "{report:?}");
617        assert!(report
618            .surface_evaluations
619            .iter()
620            .all(|s| s.verdict == TokenSurfaceVerdict::Pass));
621    }
622
623    #[test]
624    fn token_gate_blocks_when_surface_is_missing() {
625        let mut entries = Vec::new();
626        for surface in &TOKEN_GATE_SURFACES[..4] {
627            for i in 1..=3 {
628                entries.push(synth_token_sample(
629                    &format!("synth-{surface}-2026-06-02-sample-{i}"),
630                    surface,
631                    500.0,
632                    2048.0,
633                    150_000.0,
634                    85.0,
635                    12.0,
636                    0.72,
637                ));
638            }
639        }
640        let raw = build_token_history(entries);
641        let history = parse_token_history(&raw).unwrap();
642        let report = evaluate_token_gate(&history, 10.0);
643        assert_eq!(report.decision, TokenGateDecision::Block);
644        let missing = report
645            .surface_evaluations
646            .iter()
647            .filter(|s| s.verdict == TokenSurfaceVerdict::Missing)
648            .count();
649        assert_eq!(missing, 1);
650    }
651
652    #[test]
653    fn token_gate_blocks_when_insufficient_samples() {
654        let mut entries = Vec::new();
655        for surface in TOKEN_GATE_SURFACES {
656            for i in 1..=2 {
657                entries.push(synth_token_sample(
658                    &format!("synth-{surface}-2026-06-02-sample-{i}"),
659                    surface,
660                    500.0,
661                    2048.0,
662                    150_000.0,
663                    85.0,
664                    12.0,
665                    0.72,
666                ));
667            }
668        }
669        let raw = build_token_history(entries);
670        let history = parse_token_history(&raw).unwrap();
671        let report = evaluate_token_gate(&history, 10.0);
672        assert_eq!(report.decision, TokenGateDecision::Block);
673        assert!(report
674            .surface_evaluations
675            .iter()
676            .all(|s| s.verdict == TokenSurfaceVerdict::InsufficientSamples));
677    }
678
679    #[test]
680    fn token_regression_passes_when_candidate_matches_baseline() {
681        let raw = full_token_history_three_samples_each();
682        let baseline = parse_token_history(&raw).unwrap();
683        let candidate = baseline.clone();
684        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
685        assert_eq!(report.decision, TokenGateDecision::Pass, "{report:?}");
686    }
687
688    #[test]
689    fn token_regression_blocks_when_lower_is_better_metric_regresses() {
690        let mut baseline_entries = Vec::new();
691        let mut candidate_entries = Vec::new();
692        for surface in TOKEN_GATE_SURFACES {
693            for i in 1..=3 {
694                baseline_entries.push(synth_token_sample(
695                    &format!("base-{surface}-sample-{i}"),
696                    surface,
697                    500.0,
698                    2048.0,
699                    150_000.0,
700                    85.0,
701                    12.0,
702                    0.72,
703                ));
704                candidate_entries.push(synth_token_sample(
705                    &format!("cand-{surface}-sample-{i}"),
706                    surface,
707                    5000.0,
708                    2048.0,
709                    150_000.0,
710                    85.0,
711                    12.0,
712                    0.72,
713                ));
714            }
715        }
716        let baseline = parse_token_history(&build_token_history(baseline_entries)).unwrap();
717        let candidate = parse_token_history(&build_token_history(candidate_entries)).unwrap();
718        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
719        assert_eq!(report.decision, TokenGateDecision::Block);
720        assert!(report.surface_evaluations.iter().all(|s| {
721            s.metric_evaluations
722                .iter()
723                .find(|m| m.metric == "prompt_tokens")
724                .is_some_and(|m| !m.passed)
725        }));
726    }
727
728    #[test]
729    fn token_regression_blocks_when_higher_is_better_metric_regresses() {
730        let mut baseline_entries = Vec::new();
731        let mut candidate_entries = Vec::new();
732        for surface in TOKEN_GATE_SURFACES {
733            for i in 1..=3 {
734                baseline_entries.push(synth_token_sample(
735                    &format!("base-{surface}-sample-{i}"),
736                    surface,
737                    500.0,
738                    2048.0,
739                    150_000.0,
740                    85.0,
741                    12.0,
742                    0.72,
743                ));
744                candidate_entries.push(synth_token_sample(
745                    &format!("cand-{surface}-sample-{i}"),
746                    surface,
747                    500.0,
748                    2048.0,
749                    150_000.0,
750                    10.0,
751                    12.0,
752                    0.72,
753                ));
754            }
755        }
756        let baseline = parse_token_history(&build_token_history(baseline_entries)).unwrap();
757        let candidate = parse_token_history(&build_token_history(candidate_entries)).unwrap();
758        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
759        assert_eq!(report.decision, TokenGateDecision::Block);
760    }
761
762    #[test]
763    fn token_regression_blocks_when_both_missing() {
764        let baseline = parse_token_history(&build_token_history(vec![])).unwrap();
765        let candidate = parse_token_history(&build_token_history(vec![])).unwrap();
766        let report = evaluate_token_regression(&baseline, &candidate, 10.0);
767        assert_eq!(report.decision, TokenGateDecision::Block);
768        assert!(report
769            .surface_evaluations
770            .iter()
771            .all(|s| s.verdict == TokenSurfaceVerdict::Missing));
772    }
773}