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> =
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}