1use 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}