Skip to main content

tsift_quality/
perf_gate.rs

1//! Graph DB performance release gate.
2//!
3//! Turns repeated `tsift graph-db backend-eval` samples (recorded in
4//! `fixtures/graph-db-performance-history.json`) into a binding promotion
5//! decision for candidate `GraphStore` backends.
6//!
7//! Spec: see specs/graph.md § "Graph DB Performance Release Gate".
8//!
9//! Four required workloads (canonical fixture prefix → human gate name):
10//!
11//! | Fixture metric prefix     | Gate workload name |
12//! |---------------------------|--------------------|
13//! | `real`                    | `default`          |
14//! | `full_projection`         | `full-projection`  |
15//! | `synthetic_high_degree`   | `high-degree`      |
16//! | `synthetic_deep_chain`    | `deep-chain`       |
17//!
18//! Promotion rule: a candidate backend (FalkorDB, Kuzu, DuckDB/DuckPGQ,
19//! Ladybug, ...) stays blocked until it beats the stabilized SQLite gate on
20//! **every** workload across **every** required metric — including
21//! `refresh.duration_micros` (projection write cost) and any
22//! `lock_wait_micros` / `lock_contention_micros` metric where applicable.
23//!
24//! At least three samples per workload are required before the gate emits a
25//! binding promote/block call.
26
27use anyhow::{Context, Result, bail};
28use serde::Serialize;
29use serde_json::Value;
30use std::collections::BTreeMap;
31
32/// SQLite is the stabilized baseline backend; every candidate must beat it.
33pub const BASELINE_BACKEND: &str = "sqlite";
34
35/// Minimum number of samples per workload before the gate's decision is binding.
36pub const MIN_SAMPLES_PER_WORKLOAD: usize = 3;
37
38/// User-facing graph path default. Higher hop tiers stay benchmark-only until
39/// `evaluate_hop_cap_promotion` returns `Promote`.
40pub const HOP_CAP_CURRENT_DEFAULT: usize = 64;
41
42/// Higher hop tiers that backend-eval records as promotion candidates.
43pub const HOP_CAP_CANDIDATE_TIERS: [usize; 3] = [128, 256, 512];
44
45/// Workloads that must prove higher hop caps before the default can move.
46pub const HOP_CAP_REQUIRED_WORKLOADS: [&str; 3] =
47    ["real", "full_projection", "synthetic_deep_chain"];
48
49/// Fixture metric prefixes for the four required gate workloads, in canonical
50/// fixture order.
51pub const GATE_WORKLOAD_PREFIXES: [&str; 4] = [
52    "real",
53    "full_projection",
54    "synthetic_high_degree",
55    "synthetic_deep_chain",
56];
57
58/// Mapping from fixture metric prefix to the human-readable gate workload
59/// name used in SPEC and operator-facing diagnostics.
60pub 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
70/// Per-operation metrics that the gate considers binding on every workload.
71/// `refresh.duration_micros` is the projection write cost; SQLite's bundled
72/// install and lock behavior cannot be sacrificed for a faster read path on
73/// any other operation.
74pub const REQUIRED_GATE_METRICS: [&str; 2] = ["refresh.duration_micros", "total_duration_micros"];
75
76/// Lock-behavior metrics. If any candidate produces a lock-wait metric on a
77/// workload it must also be ≤ SQLite's median for that metric. Absent
78/// lock-wait metrics are not by themselves a block — sibling agents are still
79/// wiring projection-write lock instrumentation, and the gate refuses to
80/// invent evidence.
81pub const LOCK_BEHAVIOR_METRIC_SUFFIXES: [&str; 2] = ["lock_wait_micros", "lock_contention_micros"];
82
83/// A single fixture run entry, normalized for gate consumption.
84#[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    /// Fixture workload prefix discovered in this sample (e.g. `real`,
91    /// `full_projection`). A single sample can carry one or more workloads.
92    pub workload_prefixes: Vec<String>,
93    /// Sample index parsed from the run id (`...sample-3` → `3`). Falls back
94    /// to `None` when the id does not encode an index.
95    pub sample_index: Option<usize>,
96    /// Backends present for each workload prefix (deduplicated, sorted).
97    pub backends_by_workload: BTreeMap<String, Vec<String>>,
98    /// All numeric metrics, keyed by the original fixture metric key.
99    pub metrics: BTreeMap<String, f64>,
100}
101
102/// Diagnostic verdict for a single workload.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "snake_case")]
105pub enum WorkloadVerdict {
106    /// Candidate beat SQLite by at least `threshold` on every required metric
107    /// and matched or beat SQLite on every observed lock-behavior metric.
108    Beats,
109    /// Candidate failed at least one required metric or lock-behavior metric.
110    Regresses,
111    /// Fewer than `MIN_SAMPLES_PER_WORKLOAD` samples; insufficient evidence.
112    InsufficientSamples,
113    /// No samples carried this workload at all.
114    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/// Diagnostic verdict for one workload in the hop-cap promotion gate.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "snake_case")]
146pub enum HopCapWorkloadVerdict {
147    /// The candidate hop tier stayed within the allowed latency band and
148    /// returned useful path rows for this workload.
149    Promotable,
150    /// The candidate tier was present but did not satisfy latency or row
151    /// usefulness requirements.
152    Hold,
153    /// Fewer than `MIN_SAMPLES_PER_WORKLOAD` samples; insufficient evidence.
154    InsufficientSamples,
155    /// No samples carried this workload at all.
156    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
181/// Parse `fixtures/graph-db-performance-history.json` (or equivalent input)
182/// into normalized `GateSample` records.
183pub 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    // Convention: `<scope>-<workload>-<date>-sample-<N>`.
263    let tail = id.rsplit("sample-").next()?;
264    if tail == id {
265        return None;
266    }
267    tail.parse::<usize>().ok()
268}
269
270/// Discover every workload prefix + per-workload backend list present in this
271/// metrics map. We look at keys of the form `<workload>.<backend>.<...>`.
272fn 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        // Skip workload-summary keys like `full_projection.edges` where the
287        // second segment is not a backend id (it has no further `.suffix`).
288        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
310/// Compute the per-metric median across a slice of f64 samples. Returns
311/// `None` if the slice is empty. Uses the simple "middle element of a sorted
312/// copy" definition (averaging the two middle elements for even counts).
313fn 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
330/// Collect samples for `workload_prefix` from history, returning per-metric
331/// vectors keyed by `(backend, metric_suffix)`.
332fn 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
382/// Evaluate whether a measured higher hop tier can replace the user-facing
383/// `64`-hop default.
384///
385/// The gate is intentionally stricter than merely checking that the raw
386/// metrics exist. It requires repeated SQLite samples on the real,
387/// full-projection, and synthetic deep-chain workloads, keeps the higher tier
388/// within the configured latency-regression budget relative to the 64-hop
389/// baseline, and proves the higher tier returns useful rows. On the synthetic
390/// deep-chain workload, "useful" means the higher cap returns more path rows
391/// than the 64-hop cap.
392pub 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
600/// Evaluate the promotion gate for a candidate backend against the baseline
601/// (SQLite). `improvement_threshold` is the multiplicative improvement
602/// required for each required metric (e.g. `0.0` accepts parity, `0.05`
603/// requires the candidate to be ≥ 5% faster than SQLite's median).
604pub 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        // Verify the required metrics + lock-behavior metrics for this candidate.
650        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                    // Lower is better for duration metrics. Candidate must be
665                    // at most (1 - threshold) * baseline.
666                    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        // Lock-behavior metrics: only enforce when the candidate actually
694        // reports them. Missing lock metrics are an instrumentation gap (sibling
695        // agents own that work), not a regression on this gate's part.
696        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/// Verdict for the conflict-matrix preparation hotspot regression gate.
761///
762/// `#gdbprephot`: each tsift release that reduces a preparation hotspot pins
763/// the new ceiling here so a later refactor cannot quietly grow the same
764/// phase back past its post-fix budget. The gate is fail-closed and refuses
765/// to "trust stale ownership" — callers must hand it freshly acquired
766/// samples; the gate never caches the previous comparison.
767#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
768#[serde(rename_all = "snake_case")]
769pub enum PreparationHotspotVerdict {
770    /// Sample median is at or below the budget ceiling.
771    Within,
772    /// Sample median exceeded the budget ceiling.
773    Regressed,
774    /// Fewer than the required sample count was supplied; the gate refuses
775    /// to make a binding decision.
776    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    /// Median across the supplied freshly-acquired samples. `None` when too
786    /// few samples were supplied for the gate to compute a median.
787    pub observed_median_micros: Option<u128>,
788    pub verdict: PreparationHotspotVerdict,
789    pub diagnostics: Vec<String>,
790}
791
792/// Minimum sample count for the preparation-hotspot regression gate to emit
793/// a binding decision (matches the existing backend-eval gate's three-sample
794/// median contract).
795pub const MIN_HOTSPOT_SAMPLES: usize = 3;
796
797/// Evaluate whether a `conflict_matrix_preparation` phase's freshly observed
798/// median exceeds `budget_micros`.
799///
800/// Callers MUST pass freshly-acquired samples — the gate does not cache or
801/// persist prior measurements. This matches `#gdbprephot`'s constraint that
802/// the gate "compare freshly-acquired samples, not cached prior-run values".
803pub 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
856/// Static budget for `conflict_matrix_preparation.context_pack_diff` after
857/// `#gdbprephot` capped working-tree parsing to the preview budget. The
858/// 0.1.48 pre-fix median on agent-loop was ~446 ms; the post-fix three-sample
859/// median is ~289 ms (~35 % reduction). We pin the ceiling at 350 ms so
860/// modest noise and small repo growth do not flap the gate, while a real
861/// regression (`max_parsed_files` removed, unbounded parse re-introduced,
862/// per-file `git show HEAD:path` revived for every working-tree change) trips
863/// it well before climbing back to the pre-fix ~445 ms band.
864pub 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); // candidate slower than sqlite
1020        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); // candidate 2x faster than sqlite
1034        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        // 3 samples for three workloads, 2 samples for the fourth.
1053        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        // Only `real` workload — `full_projection`, `synthetic_high_degree`,
1080        // `synthetic_deep_chain` are absent.
1081        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), // candidate has nasty lock contention
1128                );
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    // ---- #gdbprephot: preparation hotspot regression gate ----
1235
1236    #[test]
1237    fn preparation_hotspot_within_budget_passes() {
1238        let report = evaluate_preparation_hotspot(
1239            "conflict_matrix_preparation.context_pack_diff",
1240            // medians around ~80ms after #gdbprephot fix
1241            &[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        // Simulate the pre-fix 0.1.48 baseline (~446ms median).
1257        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        // Four samples: median is average of middle two.
1285        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    /// Caller must hand over freshly-acquired samples each call: the gate
1305    /// has no internal state to pollute. This test locks the contract by
1306    /// running two evaluations in a row and verifying neither result carries
1307    /// over from the other.
1308    #[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        // Crucially: `slow` did not inherit `fast`'s median; the gate refuses
1326        // to "trust stale ownership" of prior measurements.
1327    }
1328}