1use anyhow::{Context, Result, bail};
28use serde::Serialize;
29use serde_json::Value;
30use std::collections::BTreeMap;
31
32pub const BASELINE_BACKEND: &str = "sqlite";
34
35pub const MIN_SAMPLES_PER_WORKLOAD: usize = 3;
37
38pub const HOP_CAP_CURRENT_DEFAULT: usize = 64;
41
42pub const HOP_CAP_CANDIDATE_TIERS: [usize; 3] = [128, 256, 512];
44
45pub const HOP_CAP_REQUIRED_WORKLOADS: [&str; 3] =
47 ["real", "full_projection", "synthetic_deep_chain"];
48
49pub const GATE_WORKLOAD_PREFIXES: [&str; 4] = [
52 "real",
53 "full_projection",
54 "synthetic_high_degree",
55 "synthetic_deep_chain",
56];
57
58pub fn workload_display_name(prefix: &str) -> &'static str {
61 match prefix {
62 "real" => "default",
63 "full_projection" => "full-projection",
64 "synthetic_high_degree" => "high-degree",
65 "synthetic_deep_chain" => "deep-chain",
66 _ => "unknown",
67 }
68}
69
70pub const REQUIRED_GATE_METRICS: [&str; 2] = ["refresh.duration_micros", "total_duration_micros"];
75
76pub const LOCK_BEHAVIOR_METRIC_SUFFIXES: [&str; 2] = ["lock_wait_micros", "lock_contention_micros"];
82
83#[derive(Debug, Clone, PartialEq, Serialize)]
85pub struct GateSample {
86 pub label: String,
87 pub id: String,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub timestamp: Option<String>,
90 pub workload_prefixes: Vec<String>,
93 pub sample_index: Option<usize>,
96 pub backends_by_workload: BTreeMap<String, Vec<String>>,
98 pub metrics: BTreeMap<String, f64>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "snake_case")]
105pub enum WorkloadVerdict {
106 Beats,
109 Regresses,
111 InsufficientSamples,
113 Missing,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize)]
118pub struct WorkloadEvaluation {
119 pub workload: String,
120 pub display_name: String,
121 pub sample_count: usize,
122 pub verdict: WorkloadVerdict,
123 pub diagnostics: Vec<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
127#[serde(rename_all = "snake_case")]
128pub enum GateDecision {
129 Promote,
130 Block,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize)]
134pub struct GateReport {
135 pub candidate_backend: String,
136 pub baseline_backend: String,
137 pub min_samples_per_workload: usize,
138 pub workload_evaluations: Vec<WorkloadEvaluation>,
139 pub decision: GateDecision,
140 pub diagnostics: Vec<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "snake_case")]
146pub enum HopCapWorkloadVerdict {
147 Promotable,
150 Hold,
153 InsufficientSamples,
155 Missing,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize)]
160pub struct HopCapWorkloadEvaluation {
161 pub workload: String,
162 pub display_name: String,
163 pub sample_count: usize,
164 pub verdict: HopCapWorkloadVerdict,
165 pub diagnostics: Vec<String>,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize)]
169pub struct HopCapGateReport {
170 pub backend: String,
171 pub current_default_hops: usize,
172 pub candidate_hops: usize,
173 pub min_samples_per_workload: usize,
174 pub allowed_regression_percent: f64,
175 pub required_workloads: Vec<String>,
176 pub workload_evaluations: Vec<HopCapWorkloadEvaluation>,
177 pub decision: GateDecision,
178 pub diagnostics: Vec<String>,
179}
180
181pub fn parse_history(raw: &str) -> Result<Vec<GateSample>> {
184 let value: Value =
185 serde_json::from_str(raw).context("perf_gate: failed to parse history JSON")?;
186 let runs = match value {
187 Value::Object(mut obj) => match obj.remove("runs") {
188 Some(Value::Array(arr)) => arr,
189 Some(other) => bail!(
190 "perf_gate: history `runs` field must be an array, got {}",
191 value_type(&other)
192 ),
193 None => bail!("perf_gate: history JSON object missing `runs` array"),
194 },
195 Value::Array(arr) => arr,
196 other => bail!(
197 "perf_gate: history root must be object or array, got {}",
198 value_type(&other)
199 ),
200 };
201
202 let mut samples = Vec::with_capacity(runs.len());
203 for (idx, run) in runs.into_iter().enumerate() {
204 let obj = run
205 .as_object()
206 .with_context(|| format!("perf_gate: run #{idx} must be a JSON object"))?;
207 let label = obj
208 .get("label")
209 .and_then(|v| v.as_str())
210 .with_context(|| format!("perf_gate: run #{idx} missing string `label`"))?
211 .to_string();
212 let id = obj
213 .get("id")
214 .and_then(|v| v.as_str())
215 .with_context(|| format!("perf_gate: run #{idx} missing string `id`"))?
216 .to_string();
217 let timestamp = obj
218 .get("timestamp")
219 .and_then(|v| v.as_str())
220 .map(|s| s.to_string());
221 let metrics_value = obj
222 .get("metrics")
223 .with_context(|| format!("perf_gate: run #{idx} missing `metrics` map"))?;
224 let metrics_obj = metrics_value
225 .as_object()
226 .with_context(|| format!("perf_gate: run #{idx} `metrics` must be an object"))?;
227 let mut metrics = BTreeMap::new();
228 for (key, value) in metrics_obj {
229 if let Some(n) = value.as_f64() {
230 metrics.insert(key.clone(), n);
231 }
232 }
233
234 let (workload_prefixes, backends_by_workload) = derive_workloads_and_backends(&metrics);
235 let sample_index = parse_sample_index(&id);
236
237 samples.push(GateSample {
238 label,
239 id,
240 timestamp,
241 workload_prefixes,
242 sample_index,
243 backends_by_workload,
244 metrics,
245 });
246 }
247 Ok(samples)
248}
249
250fn value_type(v: &Value) -> &'static str {
251 match v {
252 Value::Null => "null",
253 Value::Bool(_) => "bool",
254 Value::Number(_) => "number",
255 Value::String(_) => "string",
256 Value::Array(_) => "array",
257 Value::Object(_) => "object",
258 }
259}
260
261fn parse_sample_index(id: &str) -> Option<usize> {
262 let tail = id.rsplit("sample-").next()?;
264 if tail == id {
265 return None;
266 }
267 tail.parse::<usize>().ok()
268}
269
270fn derive_workloads_and_backends(
273 metrics: &BTreeMap<String, f64>,
274) -> (Vec<String>, BTreeMap<String, Vec<String>>) {
275 let mut by_workload: BTreeMap<String, BTreeMap<String, ()>> = BTreeMap::new();
276 for key in metrics.keys() {
277 let mut parts = key.splitn(3, '.');
278 let workload = match parts.next() {
279 Some(w) => w,
280 None => continue,
281 };
282 let backend = match parts.next() {
283 Some(b) => b,
284 None => continue,
285 };
286 if parts.next().is_none() {
289 continue;
290 }
291 if !GATE_WORKLOAD_PREFIXES.contains(&workload) {
292 continue;
293 }
294 by_workload
295 .entry(workload.to_string())
296 .or_default()
297 .insert(backend.to_string(), ());
298 }
299 let mut workload_prefixes = Vec::with_capacity(by_workload.len());
300 let mut backends_by_workload = BTreeMap::new();
301 for (workload, backends) in by_workload {
302 workload_prefixes.push(workload.clone());
303 let mut backend_list: Vec<String> = backends.into_keys().collect();
304 backend_list.sort();
305 backends_by_workload.insert(workload, backend_list);
306 }
307 (workload_prefixes, backends_by_workload)
308}
309
310fn median(values: &[f64]) -> Option<f64> {
314 if values.is_empty() {
315 return None;
316 }
317 let mut sorted: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
318 if sorted.is_empty() {
319 return None;
320 }
321 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
322 let n = sorted.len();
323 if n.is_multiple_of(2) {
324 Some((sorted[n / 2 - 1] + sorted[n / 2]) / 2.0)
325 } else {
326 Some(sorted[n / 2])
327 }
328}
329
330fn collect_workload_metrics(
333 history: &[GateSample],
334 workload_prefix: &str,
335) -> (usize, BTreeMap<(String, String), Vec<f64>>) {
336 let mut sample_count = 0usize;
337 let mut per_metric: BTreeMap<(String, String), Vec<f64>> = BTreeMap::new();
338 for sample in history {
339 if !sample
340 .workload_prefixes
341 .iter()
342 .any(|w| w == workload_prefix)
343 {
344 continue;
345 }
346 sample_count += 1;
347 let prefix = format!("{workload_prefix}.");
348 for (key, value) in &sample.metrics {
349 let rest = match key.strip_prefix(&prefix) {
350 Some(r) => r,
351 None => continue,
352 };
353 let (backend, suffix) = match rest.split_once('.') {
354 Some(s) => s,
355 None => continue,
356 };
357 per_metric
358 .entry((backend.to_string(), suffix.to_string()))
359 .or_default()
360 .push(*value);
361 }
362 }
363 (sample_count, per_metric)
364}
365
366fn path_hop_metric_suffix(hops: usize, leaf: &str) -> String {
367 if hops == HOP_CAP_CURRENT_DEFAULT {
368 format!("path_max_hops.{leaf}")
369 } else {
370 format!("path_max_hops_{hops}.{leaf}")
371 }
372}
373
374fn metric_median_with_min_samples(values: &[f64]) -> Option<f64> {
375 if values.len() < MIN_SAMPLES_PER_WORKLOAD {
376 None
377 } else {
378 median(values)
379 }
380}
381
382pub fn evaluate_hop_cap_promotion(
393 history: &[GateSample],
394 candidate_hops: usize,
395 allowed_regression_percent: f64,
396) -> HopCapGateReport {
397 let mut workload_evaluations = Vec::with_capacity(HOP_CAP_REQUIRED_WORKLOADS.len());
398 let mut diagnostics = Vec::new();
399 let mut any_block = false;
400
401 if candidate_hops <= HOP_CAP_CURRENT_DEFAULT {
402 any_block = true;
403 diagnostics.push(format!(
404 "candidate hop tier {candidate_hops} must be greater than current default {HOP_CAP_CURRENT_DEFAULT}"
405 ));
406 } else if !HOP_CAP_CANDIDATE_TIERS.contains(&candidate_hops) {
407 any_block = true;
408 diagnostics.push(format!(
409 "candidate hop tier {candidate_hops} is not one of the measured promotion tiers {:?}",
410 HOP_CAP_CANDIDATE_TIERS
411 ));
412 }
413
414 let allowed_multiplier = 1.0 + (allowed_regression_percent / 100.0);
415 let baseline_duration_suffix =
416 path_hop_metric_suffix(HOP_CAP_CURRENT_DEFAULT, "duration_micros");
417 let baseline_rows_suffix = path_hop_metric_suffix(HOP_CAP_CURRENT_DEFAULT, "rows");
418 let candidate_duration_suffix = path_hop_metric_suffix(candidate_hops, "duration_micros");
419 let candidate_rows_suffix = path_hop_metric_suffix(candidate_hops, "rows");
420
421 for prefix in HOP_CAP_REQUIRED_WORKLOADS {
422 let display = workload_display_name(prefix).to_string();
423 let (sample_count, per_metric) = collect_workload_metrics(history, prefix);
424 let mut workload_diagnostics = Vec::new();
425
426 if sample_count == 0 {
427 any_block = true;
428 workload_evaluations.push(HopCapWorkloadEvaluation {
429 workload: prefix.to_string(),
430 display_name: display.clone(),
431 sample_count,
432 verdict: HopCapWorkloadVerdict::Missing,
433 diagnostics: vec![format!(
434 "workload `{display}` has no samples; hop-cap promotion requires {MIN_SAMPLES_PER_WORKLOAD}"
435 )],
436 });
437 diagnostics.push(format!("`{display}`: missing"));
438 continue;
439 }
440 if sample_count < MIN_SAMPLES_PER_WORKLOAD {
441 any_block = true;
442 workload_evaluations.push(HopCapWorkloadEvaluation {
443 workload: prefix.to_string(),
444 display_name: display.clone(),
445 sample_count,
446 verdict: HopCapWorkloadVerdict::InsufficientSamples,
447 diagnostics: vec![format!(
448 "workload `{display}` has {sample_count} sample(s); hop-cap promotion requires {MIN_SAMPLES_PER_WORKLOAD}"
449 )],
450 });
451 diagnostics.push(format!(
452 "`{display}`: only {sample_count}/{MIN_SAMPLES_PER_WORKLOAD} samples"
453 ));
454 continue;
455 }
456
457 let baseline_duration_values = per_metric
458 .get(&(
459 BASELINE_BACKEND.to_string(),
460 baseline_duration_suffix.clone(),
461 ))
462 .cloned()
463 .unwrap_or_default();
464 let candidate_duration_values = per_metric
465 .get(&(
466 BASELINE_BACKEND.to_string(),
467 candidate_duration_suffix.clone(),
468 ))
469 .cloned()
470 .unwrap_or_default();
471 let baseline_rows_values = per_metric
472 .get(&(BASELINE_BACKEND.to_string(), baseline_rows_suffix.clone()))
473 .cloned()
474 .unwrap_or_default();
475 let candidate_rows_values = per_metric
476 .get(&(BASELINE_BACKEND.to_string(), candidate_rows_suffix.clone()))
477 .cloned()
478 .unwrap_or_default();
479
480 let mut verdict = HopCapWorkloadVerdict::Promotable;
481
482 match (
483 metric_median_with_min_samples(&baseline_duration_values),
484 metric_median_with_min_samples(&candidate_duration_values),
485 ) {
486 (Some(base), Some(candidate)) => {
487 let allowed = base * allowed_multiplier;
488 if candidate <= allowed {
489 workload_diagnostics.push(format!(
490 "`{candidate_duration_suffix}` median {candidate:.1}µs ≤ allowed {allowed:.1}µs (64-hop baseline {base:.1}µs)"
491 ));
492 } else {
493 verdict = HopCapWorkloadVerdict::Hold;
494 workload_diagnostics.push(format!(
495 "`{candidate_duration_suffix}` REGRESSES: median {candidate:.1}µs > allowed {allowed:.1}µs (64-hop baseline {base:.1}µs)"
496 ));
497 }
498 }
499 (None, Some(_)) => {
500 verdict = HopCapWorkloadVerdict::Hold;
501 workload_diagnostics.push(format!(
502 "`{baseline_duration_suffix}` has fewer than {MIN_SAMPLES_PER_WORKLOAD} samples"
503 ));
504 }
505 (Some(_), None) => {
506 verdict = HopCapWorkloadVerdict::Hold;
507 workload_diagnostics.push(format!(
508 "`{candidate_duration_suffix}` has fewer than {MIN_SAMPLES_PER_WORKLOAD} samples"
509 ));
510 }
511 (None, None) => {
512 verdict = HopCapWorkloadVerdict::Hold;
513 workload_diagnostics.push(format!(
514 "`{baseline_duration_suffix}` and `{candidate_duration_suffix}` have fewer than {MIN_SAMPLES_PER_WORKLOAD} samples"
515 ));
516 }
517 }
518
519 match (
520 metric_median_with_min_samples(&baseline_rows_values),
521 metric_median_with_min_samples(&candidate_rows_values),
522 ) {
523 (Some(base_rows), Some(candidate_rows)) if candidate_rows > 0.0 => {
524 let useful = if prefix == "synthetic_deep_chain" {
525 candidate_rows > base_rows
526 } else {
527 candidate_rows >= base_rows
528 };
529 if useful {
530 workload_diagnostics.push(format!(
531 "`{candidate_rows_suffix}` median {candidate_rows:.1} row(s) proves useful output against 64-hop baseline {base_rows:.1}"
532 ));
533 } else {
534 verdict = HopCapWorkloadVerdict::Hold;
535 workload_diagnostics.push(format!(
536 "`{candidate_rows_suffix}` is not useful: median {candidate_rows:.1} row(s) does not exceed required baseline {base_rows:.1}"
537 ));
538 }
539 }
540 (Some(_), Some(candidate_rows)) => {
541 verdict = HopCapWorkloadVerdict::Hold;
542 workload_diagnostics.push(format!(
543 "`{candidate_rows_suffix}` is not useful: median {candidate_rows:.1} row(s)"
544 ));
545 }
546 (None, Some(_)) => {
547 verdict = HopCapWorkloadVerdict::Hold;
548 workload_diagnostics.push(format!(
549 "`{baseline_rows_suffix}` has fewer than {MIN_SAMPLES_PER_WORKLOAD} samples"
550 ));
551 }
552 (Some(_), None) => {
553 verdict = HopCapWorkloadVerdict::Hold;
554 workload_diagnostics.push(format!(
555 "`{candidate_rows_suffix}` has fewer than {MIN_SAMPLES_PER_WORKLOAD} samples"
556 ));
557 }
558 (None, None) => {
559 verdict = HopCapWorkloadVerdict::Hold;
560 workload_diagnostics.push(format!(
561 "`{baseline_rows_suffix}` and `{candidate_rows_suffix}` have fewer than {MIN_SAMPLES_PER_WORKLOAD} samples"
562 ));
563 }
564 }
565
566 if verdict != HopCapWorkloadVerdict::Promotable {
567 any_block = true;
568 diagnostics.push(format!("`{display}`: {candidate_hops}-hop tier held"));
569 }
570
571 workload_evaluations.push(HopCapWorkloadEvaluation {
572 workload: prefix.to_string(),
573 display_name: display,
574 sample_count,
575 verdict,
576 diagnostics: workload_diagnostics,
577 });
578 }
579
580 HopCapGateReport {
581 backend: BASELINE_BACKEND.to_string(),
582 current_default_hops: HOP_CAP_CURRENT_DEFAULT,
583 candidate_hops,
584 min_samples_per_workload: MIN_SAMPLES_PER_WORKLOAD,
585 allowed_regression_percent,
586 required_workloads: HOP_CAP_REQUIRED_WORKLOADS
587 .iter()
588 .map(|workload| (*workload).to_string())
589 .collect(),
590 workload_evaluations,
591 decision: if any_block {
592 GateDecision::Block
593 } else {
594 GateDecision::Promote
595 },
596 diagnostics,
597 }
598}
599
600pub fn evaluate_promotion(
605 history: &[GateSample],
606 candidate_backend: &str,
607 improvement_threshold: f64,
608) -> GateReport {
609 let mut workload_evaluations = Vec::with_capacity(GATE_WORKLOAD_PREFIXES.len());
610 let mut top_level_diagnostics = Vec::new();
611 let mut any_block = false;
612
613 for prefix in GATE_WORKLOAD_PREFIXES {
614 let display = workload_display_name(prefix).to_string();
615 let (sample_count, per_metric) = collect_workload_metrics(history, prefix);
616 let mut diagnostics = Vec::new();
617
618 if sample_count == 0 {
619 workload_evaluations.push(WorkloadEvaluation {
620 workload: prefix.to_string(),
621 display_name: display.clone(),
622 sample_count,
623 verdict: WorkloadVerdict::Missing,
624 diagnostics: vec![format!(
625 "workload `{display}` has no samples in history; gate blocks until at least {MIN_SAMPLES_PER_WORKLOAD} samples are recorded"
626 )],
627 });
628 any_block = true;
629 top_level_diagnostics.push(format!("`{display}`: missing"));
630 continue;
631 }
632 if sample_count < MIN_SAMPLES_PER_WORKLOAD {
633 workload_evaluations.push(WorkloadEvaluation {
634 workload: prefix.to_string(),
635 display_name: display.clone(),
636 sample_count,
637 verdict: WorkloadVerdict::InsufficientSamples,
638 diagnostics: vec![format!(
639 "workload `{display}` has {sample_count} sample(s); gate requires {MIN_SAMPLES_PER_WORKLOAD}"
640 )],
641 });
642 any_block = true;
643 top_level_diagnostics.push(format!(
644 "`{display}`: only {sample_count}/{MIN_SAMPLES_PER_WORKLOAD} samples"
645 ));
646 continue;
647 }
648
649 let mut verdict = WorkloadVerdict::Beats;
651 for metric_suffix in REQUIRED_GATE_METRICS {
652 let baseline_values = per_metric
653 .get(&(BASELINE_BACKEND.to_string(), metric_suffix.to_string()))
654 .cloned()
655 .unwrap_or_default();
656 let candidate_values = per_metric
657 .get(&(candidate_backend.to_string(), metric_suffix.to_string()))
658 .cloned()
659 .unwrap_or_default();
660 let baseline_median = median(&baseline_values);
661 let candidate_median = median(&candidate_values);
662 match (baseline_median, candidate_median) {
663 (Some(base), Some(cand)) => {
664 let allowed = base * (1.0 - improvement_threshold);
667 if cand <= allowed {
668 diagnostics.push(format!(
669 "`{metric_suffix}`: candidate median {cand:.1} ≤ allowed {allowed:.1} (baseline {base:.1})"
670 ));
671 } else {
672 verdict = WorkloadVerdict::Regresses;
673 diagnostics.push(format!(
674 "`{metric_suffix}` REGRESSES: candidate median {cand:.1} > allowed {allowed:.1} (baseline {base:.1})"
675 ));
676 }
677 }
678 (Some(_), None) => {
679 verdict = WorkloadVerdict::Regresses;
680 diagnostics.push(format!(
681 "`{metric_suffix}`: candidate `{candidate_backend}` produced no samples for this workload"
682 ));
683 }
684 (None, _) => {
685 verdict = WorkloadVerdict::Regresses;
686 diagnostics.push(format!(
687 "`{metric_suffix}`: baseline `{BASELINE_BACKEND}` produced no samples for this workload"
688 ));
689 }
690 }
691 }
692
693 for suffix in LOCK_BEHAVIOR_METRIC_SUFFIXES {
697 let candidate_values = per_metric
698 .get(&(candidate_backend.to_string(), suffix.to_string()))
699 .cloned()
700 .unwrap_or_default();
701 if candidate_values.is_empty() {
702 continue;
703 }
704 let baseline_values = per_metric
705 .get(&(BASELINE_BACKEND.to_string(), suffix.to_string()))
706 .cloned()
707 .unwrap_or_default();
708 let baseline_median = median(&baseline_values);
709 let candidate_median = median(&candidate_values).unwrap_or(f64::INFINITY);
710 match baseline_median {
711 Some(base) if candidate_median <= base => {
712 diagnostics.push(format!(
713 "lock metric `{suffix}`: candidate {candidate_median:.1} ≤ baseline {base:.1}"
714 ));
715 }
716 Some(base) => {
717 verdict = WorkloadVerdict::Regresses;
718 diagnostics.push(format!(
719 "lock metric `{suffix}` REGRESSES: candidate {candidate_median:.1} > baseline {base:.1}"
720 ));
721 }
722 None => {
723 verdict = WorkloadVerdict::Regresses;
724 diagnostics.push(format!(
725 "lock metric `{suffix}`: candidate reports samples but baseline `{BASELINE_BACKEND}` does not — cannot prove parity"
726 ));
727 }
728 }
729 }
730
731 if verdict != WorkloadVerdict::Beats {
732 any_block = true;
733 top_level_diagnostics.push(format!("`{display}`: candidate regresses"));
734 }
735 workload_evaluations.push(WorkloadEvaluation {
736 workload: prefix.to_string(),
737 display_name: display,
738 sample_count,
739 verdict,
740 diagnostics,
741 });
742 }
743
744 let decision = if any_block {
745 GateDecision::Block
746 } else {
747 GateDecision::Promote
748 };
749
750 GateReport {
751 candidate_backend: candidate_backend.to_string(),
752 baseline_backend: BASELINE_BACKEND.to_string(),
753 min_samples_per_workload: MIN_SAMPLES_PER_WORKLOAD,
754 workload_evaluations,
755 decision,
756 diagnostics: top_level_diagnostics,
757 }
758}
759
760#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
768#[serde(rename_all = "snake_case")]
769pub enum PreparationHotspotVerdict {
770 Within,
772 Regressed,
774 InsufficientSamples,
777}
778
779#[derive(Debug, Clone, PartialEq, Serialize)]
780pub struct PreparationHotspotReport {
781 pub phase: String,
782 pub min_samples: usize,
783 pub sample_count: usize,
784 pub budget_micros: u128,
785 pub observed_median_micros: Option<u128>,
788 pub verdict: PreparationHotspotVerdict,
789 pub diagnostics: Vec<String>,
790}
791
792pub const MIN_HOTSPOT_SAMPLES: usize = 3;
796
797pub fn evaluate_preparation_hotspot(
804 phase: &str,
805 samples: &[u128],
806 budget_micros: u128,
807) -> PreparationHotspotReport {
808 let mut diagnostics = Vec::new();
809 if samples.len() < MIN_HOTSPOT_SAMPLES {
810 diagnostics.push(format!(
811 "preparation hotspot `{phase}` needs ≥{MIN_HOTSPOT_SAMPLES} fresh samples; got {}",
812 samples.len()
813 ));
814 return PreparationHotspotReport {
815 phase: phase.to_string(),
816 min_samples: MIN_HOTSPOT_SAMPLES,
817 sample_count: samples.len(),
818 budget_micros,
819 observed_median_micros: None,
820 verdict: PreparationHotspotVerdict::InsufficientSamples,
821 diagnostics,
822 };
823 }
824 let mut sorted: Vec<u128> = samples.to_vec();
825 sorted.sort_unstable();
826 let mid = sorted.len() / 2;
827 let observed = if sorted.len().is_multiple_of(2) {
828 (sorted[mid - 1] + sorted[mid]) / 2
829 } else {
830 sorted[mid]
831 };
832 let verdict = if observed <= budget_micros {
833 diagnostics.push(format!(
834 "`{phase}` median {observed}µs ≤ budget {budget_micros}µs across {} fresh samples",
835 samples.len()
836 ));
837 PreparationHotspotVerdict::Within
838 } else {
839 diagnostics.push(format!(
840 "`{phase}` REGRESSED: median {observed}µs > budget {budget_micros}µs across {} fresh samples",
841 samples.len()
842 ));
843 PreparationHotspotVerdict::Regressed
844 };
845 PreparationHotspotReport {
846 phase: phase.to_string(),
847 min_samples: MIN_HOTSPOT_SAMPLES,
848 sample_count: samples.len(),
849 budget_micros,
850 observed_median_micros: Some(observed),
851 verdict,
852 diagnostics,
853 }
854}
855
856pub const CONTEXT_PACK_DIFF_BUDGET_MICROS: u128 = 350_000;
865
866#[cfg(test)]
867mod tests {
868 use super::*;
869
870 fn synth_sample(
871 id: &str,
872 workload: &str,
873 sample_idx: usize,
874 sqlite_us: f64,
875 cand_us: f64,
876 ) -> Value {
877 let mut metrics = serde_json::Map::new();
878 metrics.insert(
879 format!("{workload}.sqlite.refresh.duration_micros"),
880 Value::from(sqlite_us),
881 );
882 metrics.insert(
883 format!("{workload}.sqlite.total_duration_micros"),
884 Value::from(sqlite_us * 2.0),
885 );
886 metrics.insert(
887 format!("{workload}.falkordb.refresh.duration_micros"),
888 Value::from(cand_us),
889 );
890 metrics.insert(
891 format!("{workload}.falkordb.total_duration_micros"),
892 Value::from(cand_us * 2.0),
893 );
894 let mut run = serde_json::Map::new();
895 run.insert(
896 "label".into(),
897 Value::from(format!("synth {workload} sample {sample_idx}")),
898 );
899 run.insert("id".into(), Value::from(id.to_string()));
900 run.insert("timestamp".into(), Value::from("2026-05-24T00:00:00Z"));
901 run.insert("metrics".into(), Value::Object(metrics));
902 Value::Object(run)
903 }
904
905 fn build_history(samples: Vec<Value>) -> String {
906 let mut root = serde_json::Map::new();
907 root.insert("runs".into(), Value::Array(samples));
908 Value::Object(root).to_string()
909 }
910
911 #[test]
912 fn parse_history_extracts_workloads_and_sample_index() {
913 let raw = build_history(vec![synth_sample(
914 "agent-loop-full-projection-2026-05-24-sample-2",
915 "full_projection",
916 2,
917 1000.0,
918 500.0,
919 )]);
920 let samples = parse_history(&raw).unwrap();
921 assert_eq!(samples.len(), 1);
922 let s = &samples[0];
923 assert_eq!(s.workload_prefixes, vec!["full_projection".to_string()]);
924 assert_eq!(s.sample_index, Some(2));
925 assert!(
926 s.backends_by_workload
927 .get("full_projection")
928 .unwrap()
929 .contains(&"sqlite".to_string())
930 );
931 assert!(
932 s.backends_by_workload
933 .get("full_projection")
934 .unwrap()
935 .contains(&"falkordb".to_string())
936 );
937 }
938
939 fn full_history_three_samples_each(cand_us: f64) -> String {
940 let mut runs = Vec::new();
941 for prefix in GATE_WORKLOAD_PREFIXES {
942 for i in 1..=3 {
943 let id = format!("agent-loop-{prefix}-2026-05-24-sample-{i}");
944 runs.push(synth_sample(&id, prefix, i, 1000.0, cand_us));
945 }
946 }
947 build_history(runs)
948 }
949
950 fn hop_sample(
951 id: &str,
952 workload: &str,
953 sample_idx: usize,
954 base_us: f64,
955 candidate_us: f64,
956 base_rows: f64,
957 candidate_rows: f64,
958 ) -> Value {
959 let mut metrics = serde_json::Map::new();
960 metrics.insert(
961 format!("{workload}.sqlite.path_max_hops.duration_micros"),
962 Value::from(base_us),
963 );
964 metrics.insert(
965 format!("{workload}.sqlite.path_max_hops.rows"),
966 Value::from(base_rows),
967 );
968 metrics.insert(
969 format!("{workload}.sqlite.path_max_hops_512.duration_micros"),
970 Value::from(candidate_us),
971 );
972 metrics.insert(
973 format!("{workload}.sqlite.path_max_hops_512.rows"),
974 Value::from(candidate_rows),
975 );
976 let mut run = serde_json::Map::new();
977 run.insert(
978 "label".into(),
979 Value::from(format!("hop {workload} sample {sample_idx}")),
980 );
981 run.insert("id".into(), Value::from(id.to_string()));
982 run.insert("timestamp".into(), Value::from("2026-05-26T00:00:00Z"));
983 run.insert("metrics".into(), Value::Object(metrics));
984 Value::Object(run)
985 }
986
987 fn hop_history(
988 candidate_us: f64,
989 deep_candidate_rows: f64,
990 include_full_projection: bool,
991 ) -> String {
992 let mut runs = Vec::new();
993 for workload in HOP_CAP_REQUIRED_WORKLOADS {
994 if workload == "full_projection" && !include_full_projection {
995 continue;
996 }
997 for i in 1..=3 {
998 let (base_rows, candidate_rows) = if workload == "synthetic_deep_chain" {
999 (65.0, deep_candidate_rows)
1000 } else {
1001 (2.0, 2.0)
1002 };
1003 runs.push(hop_sample(
1004 &format!("agent-loop-{workload}-hop-2026-05-26-sample-{i}"),
1005 workload,
1006 i,
1007 1000.0,
1008 candidate_us,
1009 base_rows,
1010 candidate_rows,
1011 ));
1012 }
1013 }
1014 build_history(runs)
1015 }
1016
1017 #[test]
1018 fn evaluate_promotion_blocks_when_candidate_does_not_beat_baseline() {
1019 let raw = full_history_three_samples_each(2000.0); let history = parse_history(&raw).unwrap();
1021 let report = evaluate_promotion(&history, "falkordb", 0.0);
1022 assert_eq!(report.decision, GateDecision::Block);
1023 assert!(
1024 report
1025 .workload_evaluations
1026 .iter()
1027 .all(|w| matches!(w.verdict, WorkloadVerdict::Regresses))
1028 );
1029 }
1030
1031 #[test]
1032 fn evaluate_promotion_promotes_when_candidate_beats_baseline_on_every_workload() {
1033 let raw = full_history_three_samples_each(500.0); let history = parse_history(&raw).unwrap();
1035 let report = evaluate_promotion(&history, "falkordb", 0.05);
1036 assert_eq!(
1037 report.decision,
1038 GateDecision::Promote,
1039 "diagnostics: {:?}",
1040 report.diagnostics
1041 );
1042 assert!(
1043 report
1044 .workload_evaluations
1045 .iter()
1046 .all(|w| matches!(w.verdict, WorkloadVerdict::Beats))
1047 );
1048 }
1049
1050 #[test]
1051 fn evaluate_promotion_blocks_when_any_workload_has_fewer_than_three_samples() {
1052 let mut runs = Vec::new();
1054 for prefix in ["real", "full_projection", "synthetic_high_degree"] {
1055 for i in 1..=3 {
1056 let id = format!("agent-loop-{prefix}-2026-05-24-sample-{i}");
1057 runs.push(synth_sample(&id, prefix, i, 1000.0, 100.0));
1058 }
1059 }
1060 for i in 1..=2 {
1061 let id = format!("agent-loop-synthetic_deep_chain-2026-05-24-sample-{i}");
1062 runs.push(synth_sample(&id, "synthetic_deep_chain", i, 1000.0, 100.0));
1063 }
1064 let raw = build_history(runs);
1065 let history = parse_history(&raw).unwrap();
1066 let report = evaluate_promotion(&history, "falkordb", 0.0);
1067 assert_eq!(report.decision, GateDecision::Block);
1068 let deep_chain = report
1069 .workload_evaluations
1070 .iter()
1071 .find(|w| w.workload == "synthetic_deep_chain")
1072 .unwrap();
1073 assert_eq!(deep_chain.verdict, WorkloadVerdict::InsufficientSamples);
1074 assert_eq!(deep_chain.sample_count, 2);
1075 }
1076
1077 #[test]
1078 fn evaluate_promotion_blocks_when_workload_is_missing() {
1079 let mut runs = Vec::new();
1082 for i in 1..=3 {
1083 let id = format!("agent-loop-real-2026-05-24-sample-{i}");
1084 runs.push(synth_sample(&id, "real", i, 1000.0, 100.0));
1085 }
1086 let raw = build_history(runs);
1087 let history = parse_history(&raw).unwrap();
1088 let report = evaluate_promotion(&history, "falkordb", 0.0);
1089 assert_eq!(report.decision, GateDecision::Block);
1090 let missing_count = report
1091 .workload_evaluations
1092 .iter()
1093 .filter(|w| w.verdict == WorkloadVerdict::Missing)
1094 .count();
1095 assert_eq!(missing_count, 3);
1096 }
1097
1098 #[test]
1099 fn lock_behavior_metric_blocks_when_candidate_worse_than_baseline() {
1100 let mut runs = Vec::new();
1101 for prefix in GATE_WORKLOAD_PREFIXES {
1102 for i in 1..=3 {
1103 let id = format!("agent-loop-{prefix}-2026-05-24-sample-{i}");
1104 let mut metrics = serde_json::Map::new();
1105 metrics.insert(
1106 format!("{prefix}.sqlite.refresh.duration_micros"),
1107 Value::from(1000.0),
1108 );
1109 metrics.insert(
1110 format!("{prefix}.sqlite.total_duration_micros"),
1111 Value::from(2000.0),
1112 );
1113 metrics.insert(
1114 format!("{prefix}.sqlite.lock_wait_micros"),
1115 Value::from(10.0),
1116 );
1117 metrics.insert(
1118 format!("{prefix}.falkordb.refresh.duration_micros"),
1119 Value::from(100.0),
1120 );
1121 metrics.insert(
1122 format!("{prefix}.falkordb.total_duration_micros"),
1123 Value::from(200.0),
1124 );
1125 metrics.insert(
1126 format!("{prefix}.falkordb.lock_wait_micros"),
1127 Value::from(5000.0), );
1129 let mut run = serde_json::Map::new();
1130 run.insert("label".into(), Value::from(format!("lk {prefix} {i}")));
1131 run.insert("id".into(), Value::from(id));
1132 run.insert("metrics".into(), Value::Object(metrics));
1133 runs.push(Value::Object(run));
1134 }
1135 }
1136 let raw = build_history(runs);
1137 let history = parse_history(&raw).unwrap();
1138 let report = evaluate_promotion(&history, "falkordb", 0.0);
1139 assert_eq!(report.decision, GateDecision::Block);
1140 let regressing = report
1141 .workload_evaluations
1142 .iter()
1143 .filter(|w| matches!(w.verdict, WorkloadVerdict::Regresses))
1144 .count();
1145 assert_eq!(regressing, GATE_WORKLOAD_PREFIXES.len());
1146 }
1147
1148 #[test]
1149 fn hop_cap_gate_promotes_when_all_required_workloads_fit_budget() {
1150 let raw = hop_history(1050.0, 513.0, true);
1151 let history = parse_history(&raw).unwrap();
1152 let report = evaluate_hop_cap_promotion(&history, 512, 10.0);
1153 assert_eq!(report.decision, GateDecision::Promote, "{report:?}");
1154 assert_eq!(report.current_default_hops, 64);
1155 assert_eq!(report.candidate_hops, 512);
1156 assert_eq!(
1157 report.required_workloads,
1158 vec![
1159 "real".to_string(),
1160 "full_projection".to_string(),
1161 "synthetic_deep_chain".to_string()
1162 ]
1163 );
1164 assert!(
1165 report
1166 .workload_evaluations
1167 .iter()
1168 .all(|workload| workload.verdict == HopCapWorkloadVerdict::Promotable)
1169 );
1170 }
1171
1172 #[test]
1173 fn hop_cap_gate_blocks_when_full_projection_is_missing() {
1174 let raw = hop_history(900.0, 513.0, false);
1175 let history = parse_history(&raw).unwrap();
1176 let report = evaluate_hop_cap_promotion(&history, 512, 10.0);
1177 assert_eq!(report.decision, GateDecision::Block);
1178 let full_projection = report
1179 .workload_evaluations
1180 .iter()
1181 .find(|workload| workload.workload == "full_projection")
1182 .unwrap();
1183 assert_eq!(full_projection.verdict, HopCapWorkloadVerdict::Missing);
1184 }
1185
1186 #[test]
1187 fn hop_cap_gate_holds_when_candidate_tier_regresses() {
1188 let raw = hop_history(1500.0, 513.0, true);
1189 let history = parse_history(&raw).unwrap();
1190 let report = evaluate_hop_cap_promotion(&history, 512, 10.0);
1191 assert_eq!(report.decision, GateDecision::Block);
1192 assert!(report.workload_evaluations.iter().any(|workload| {
1193 workload
1194 .diagnostics
1195 .iter()
1196 .any(|diagnostic| diagnostic.contains("REGRESSES"))
1197 }));
1198 }
1199
1200 #[test]
1201 fn hop_cap_gate_blocks_unmeasured_candidate_tier() {
1202 let raw = hop_history(900.0, 513.0, true);
1203 let history = parse_history(&raw).unwrap();
1204 let report = evaluate_hop_cap_promotion(&history, 96, 10.0);
1205 assert_eq!(report.decision, GateDecision::Block);
1206 assert!(
1207 report
1208 .diagnostics
1209 .iter()
1210 .any(|diagnostic| diagnostic.contains("not one of the measured promotion tiers"))
1211 );
1212 }
1213
1214 #[test]
1215 fn hop_cap_gate_requires_deep_chain_rows_to_expand() {
1216 let raw = hop_history(900.0, 65.0, true);
1217 let history = parse_history(&raw).unwrap();
1218 let report = evaluate_hop_cap_promotion(&history, 512, 10.0);
1219 assert_eq!(report.decision, GateDecision::Block);
1220 let deep_chain = report
1221 .workload_evaluations
1222 .iter()
1223 .find(|workload| workload.workload == "synthetic_deep_chain")
1224 .unwrap();
1225 assert_eq!(deep_chain.verdict, HopCapWorkloadVerdict::Hold);
1226 assert!(
1227 deep_chain
1228 .diagnostics
1229 .iter()
1230 .any(|diagnostic| diagnostic.contains("not useful"))
1231 );
1232 }
1233
1234 #[test]
1237 fn preparation_hotspot_within_budget_passes() {
1238 let report = evaluate_preparation_hotspot(
1239 "conflict_matrix_preparation.context_pack_diff",
1240 &[60_000, 80_000, 90_000],
1242 CONTEXT_PACK_DIFF_BUDGET_MICROS,
1243 );
1244 assert_eq!(
1245 report.verdict,
1246 PreparationHotspotVerdict::Within,
1247 "{report:?}"
1248 );
1249 assert_eq!(report.observed_median_micros, Some(80_000));
1250 assert_eq!(report.sample_count, 3);
1251 assert_eq!(report.budget_micros, CONTEXT_PACK_DIFF_BUDGET_MICROS);
1252 }
1253
1254 #[test]
1255 fn preparation_hotspot_over_budget_fails_closed() {
1256 let report = evaluate_preparation_hotspot(
1258 "conflict_matrix_preparation.context_pack_diff",
1259 &[436_658, 445_507, 462_138],
1260 CONTEXT_PACK_DIFF_BUDGET_MICROS,
1261 );
1262 assert_eq!(report.verdict, PreparationHotspotVerdict::Regressed);
1263 assert_eq!(report.observed_median_micros, Some(445_507));
1264 assert!(report.diagnostics[0].contains("REGRESSED"));
1265 }
1266
1267 #[test]
1268 fn preparation_hotspot_with_fewer_than_three_samples_blocks() {
1269 let report = evaluate_preparation_hotspot(
1270 "conflict_matrix_preparation.context_pack_diff",
1271 &[10, 20],
1272 CONTEXT_PACK_DIFF_BUDGET_MICROS,
1273 );
1274 assert_eq!(
1275 report.verdict,
1276 PreparationHotspotVerdict::InsufficientSamples
1277 );
1278 assert_eq!(report.observed_median_micros, None);
1279 assert!(report.diagnostics[0].contains("≥3 fresh samples"));
1280 }
1281
1282 #[test]
1283 fn preparation_hotspot_even_sample_count_averages_two_middle_values() {
1284 let report = evaluate_preparation_hotspot(
1286 "conflict_matrix_preparation.context_pack_diff",
1287 &[100_000, 150_000, 200_000, 250_000],
1288 CONTEXT_PACK_DIFF_BUDGET_MICROS,
1289 );
1290 assert_eq!(report.observed_median_micros, Some(175_000));
1291 assert_eq!(report.verdict, PreparationHotspotVerdict::Within);
1292 }
1293
1294 #[test]
1295 fn preparation_hotspot_at_exact_budget_passes() {
1296 let report = evaluate_preparation_hotspot(
1297 "conflict_matrix_preparation.context_pack_diff",
1298 &[CONTEXT_PACK_DIFF_BUDGET_MICROS; 3],
1299 CONTEXT_PACK_DIFF_BUDGET_MICROS,
1300 );
1301 assert_eq!(report.verdict, PreparationHotspotVerdict::Within);
1302 }
1303
1304 #[test]
1309 fn preparation_hotspot_does_not_cache_prior_samples() {
1310 let fast = evaluate_preparation_hotspot(
1311 "context_pack_diff",
1312 &[10_000, 20_000, 30_000],
1313 CONTEXT_PACK_DIFF_BUDGET_MICROS,
1314 );
1315 assert_eq!(fast.verdict, PreparationHotspotVerdict::Within);
1316 assert_eq!(fast.observed_median_micros, Some(20_000));
1317
1318 let slow = evaluate_preparation_hotspot(
1319 "context_pack_diff",
1320 &[400_000, 500_000, 600_000],
1321 CONTEXT_PACK_DIFF_BUDGET_MICROS,
1322 );
1323 assert_eq!(slow.verdict, PreparationHotspotVerdict::Regressed);
1324 assert_eq!(slow.observed_median_micros, Some(500_000));
1325 }
1328}