Skip to main content

ftui_runtime/
validation_pipeline.rs

1#![forbid(unsafe_code)]
2
3//! Expected-cost validation ordering with Bayesian online learning.
4//!
5//! This module provides a validation pipeline that orders validators by
6//! expected cost, using Beta-posterior failure probabilities and early exit.
7//!
8//! # Mathematical Model
9//!
10//! Each validator `i` has:
11//! - **cost** `c_i`: measured execution time (running exponential average)
12//! - **failure probability** `p_i = α_i / (α_i + β_i)`: Beta posterior mean
13//!
14//! The optimal ordering minimises expected total cost under early-exit
15//! (stop on first failure). By the classic optimal search theorem
16//! (Blackwell 1953), the minimum-expected-cost ordering sorts validators
17//! by **decreasing `p_i / c_i`** (highest "bang per buck" first).
18//!
19//! ```text
20//! E[cost(π)] = Σ_k  c_{π_k} × Π_{j<k} (1 − p_{π_j})
21//! ```
22//!
23//! # Online Learning
24//!
25//! After each validation run the pipeline updates its Beta posteriors:
26//! - **Failure observed**: `α_i += 1`
27//! - **Success observed**: `β_i += 1`
28//!
29//! Cost estimates use an exponential moving average with configurable
30//! smoothing factor `γ ∈ (0,1]`.
31//!
32//! # Evidence Ledger
33//!
34//! Every ordering decision is recorded in an evidence ledger so that
35//! the ranking is fully explainable. Each entry contains:
36//! - validator id, p_i, c_i, score = p_i / c_i, rank
37//!
38//! # Failure Modes
39//!
40//! | Condition | Behavior | Rationale |
41//! |-----------|----------|-----------|
42//! | `c_i = 0` | Clamp to `c_min` (1μs) | Division by zero guard |
43//! | `α + β = 0` | Use prior (1, 1) | Uniform prior assumption |
44//! | All validators pass | Full cost incurred | No early exit possible |
45//! | No validators | Return success, zero cost | Vacuously valid |
46//!
47//! # Determinism
48//!
49//! Given identical history (same sequence of update calls), the ordering
50//! is fully deterministic. Ties are broken by validator index (stable sort).
51
52use std::time::Duration;
53
54/// Minimum cost floor to prevent division by zero in score computation.
55const C_MIN: Duration = Duration::from_micros(1);
56
57/// Default EMA smoothing factor for cost estimates.
58const DEFAULT_GAMMA: f64 = 0.3;
59
60/// Configuration for the validation pipeline.
61#[derive(Debug, Clone)]
62pub struct PipelineConfig {
63    /// Prior α for Beta(α, β). Higher → stronger prior belief in failure.
64    /// Default: 1.0 (uniform prior).
65    pub prior_alpha: f64,
66
67    /// Prior β for Beta(α, β). Higher → stronger prior belief in success.
68    /// Default: 1.0 (uniform prior).
69    pub prior_beta: f64,
70
71    /// EMA smoothing factor γ for cost updates. `c_new = γ·observed + (1−γ)·c_old`.
72    /// Default: 0.3.
73    pub gamma: f64,
74
75    /// Minimum cost floor. Default: 1μs.
76    pub c_min: Duration,
77}
78
79impl Default for PipelineConfig {
80    fn default() -> Self {
81        Self {
82            prior_alpha: 1.0,
83            prior_beta: 1.0,
84            gamma: DEFAULT_GAMMA,
85            c_min: C_MIN,
86        }
87    }
88}
89
90/// Per-validator statistics tracked by the pipeline.
91#[derive(Debug, Clone)]
92pub struct ValidatorStats {
93    /// Unique identifier for this validator.
94    pub id: usize,
95    /// Human-readable name (for logging/ledger).
96    pub name: String,
97    /// Beta posterior α (pseudo-count of failures + prior).
98    pub alpha: f64,
99    /// Beta posterior β (pseudo-count of successes + prior).
100    pub beta: f64,
101    /// EMA of observed cost.
102    pub cost_ema: Duration,
103    /// Total number of observations.
104    pub observations: u64,
105    /// Total failures observed.
106    pub failures: u64,
107}
108
109impl ValidatorStats {
110    /// Posterior mean failure probability: α / (α + β).
111    #[inline]
112    pub fn failure_prob(&self) -> f64 {
113        let sum = self.alpha + self.beta;
114        if sum > 0.0 {
115            self.alpha / sum
116        } else {
117            // Fall back to uniform prior Beta(1,1) when the posterior is undefined.
118            0.5
119        }
120    }
121
122    /// Score used for ordering: p / c (higher = should run earlier).
123    #[inline]
124    pub fn score(&self, c_min: Duration) -> f64 {
125        let c = self.cost_ema.max(c_min).as_secs_f64();
126        self.failure_prob() / c
127    }
128
129    /// Posterior variance: αβ / ((α+β)²(α+β+1)).
130    #[inline]
131    pub fn variance(&self) -> f64 {
132        let sum = self.alpha + self.beta;
133        if sum > 0.0 {
134            (self.alpha * self.beta) / (sum * sum * (sum + 1.0))
135        } else {
136            // Beta(1,1) variance.
137            1.0 / 12.0
138        }
139    }
140
141    /// 95% credible interval width (normal approximation for large α+β).
142    #[inline]
143    pub fn confidence_width(&self) -> f64 {
144        2.0 * 1.96 * self.variance().sqrt()
145    }
146}
147
148/// A single entry in the evidence ledger recording an ordering decision.
149#[derive(Debug, Clone)]
150pub struct LedgerEntry {
151    /// Validator id.
152    pub id: usize,
153    /// Validator name.
154    pub name: String,
155    /// Failure probability at decision time.
156    pub p: f64,
157    /// Cost estimate at decision time.
158    pub c: Duration,
159    /// Score = p / c.
160    pub score: f64,
161    /// Assigned rank (0 = first to run).
162    pub rank: usize,
163}
164
165/// Result of running one validation.
166#[derive(Debug, Clone)]
167pub struct ValidationOutcome {
168    /// Validator id.
169    pub id: usize,
170    /// Whether validation passed.
171    pub passed: bool,
172    /// Observed execution time.
173    pub duration: Duration,
174}
175
176/// Result of running the full pipeline.
177#[derive(Debug, Clone)]
178pub struct PipelineResult {
179    /// Whether all validators passed (or pipeline is empty).
180    pub all_passed: bool,
181    /// Outcomes for each validator that actually ran (in execution order).
182    pub outcomes: Vec<ValidationOutcome>,
183    /// Total wall time of all validators that ran.
184    pub total_cost: Duration,
185    /// The ordering that was used (validator ids in execution order).
186    pub ordering: Vec<usize>,
187    /// Evidence ledger for this run.
188    pub ledger: Vec<LedgerEntry>,
189    /// Number of validators skipped due to early exit.
190    pub skipped: usize,
191}
192
193/// Expected-cost validation pipeline with Bayesian ordering.
194#[derive(Debug, Clone)]
195pub struct ValidationPipeline {
196    config: PipelineConfig,
197    validators: Vec<ValidatorStats>,
198    /// Running count of pipeline invocations.
199    total_runs: u64,
200}
201
202impl ValidationPipeline {
203    /// Create a new pipeline with default config.
204    pub fn new() -> Self {
205        Self {
206            config: PipelineConfig::default(),
207            validators: Vec::new(),
208            total_runs: 0,
209        }
210    }
211
212    /// Create a new pipeline with custom config.
213    pub fn with_config(config: PipelineConfig) -> Self {
214        Self {
215            config,
216            validators: Vec::new(),
217            total_runs: 0,
218        }
219    }
220
221    /// Register a validator with a name and initial cost estimate.
222    /// Returns the assigned id.
223    pub fn register(&mut self, name: impl Into<String>, initial_cost: Duration) -> usize {
224        let id = self.validators.len();
225        self.validators.push(ValidatorStats {
226            id,
227            name: name.into(),
228            alpha: self.config.prior_alpha,
229            beta: self.config.prior_beta,
230            cost_ema: initial_cost.max(self.config.c_min),
231            observations: 0,
232            failures: 0,
233        });
234        id
235    }
236
237    /// Compute the optimal ordering (decreasing p/c score).
238    /// Returns validator ids in execution order, plus the evidence ledger.
239    pub fn compute_ordering(&self) -> (Vec<usize>, Vec<LedgerEntry>) {
240        if self.validators.is_empty() {
241            return (Vec::new(), Vec::new());
242        }
243
244        // Compute scores and sort by decreasing score (highest bang-per-buck first).
245        let mut scored: Vec<(usize, f64)> = self
246            .validators
247            .iter()
248            .map(|v| (v.id, v.score(self.config.c_min)))
249            .collect();
250
251        // Stable sort: ties broken by id (lower id first).
252        scored.sort_by(|a, b| {
253            b.1.partial_cmp(&a.1)
254                .unwrap_or(std::cmp::Ordering::Equal)
255                .then_with(|| a.0.cmp(&b.0))
256        });
257
258        let ordering: Vec<usize> = scored.iter().map(|(id, _)| *id).collect();
259
260        let ledger: Vec<LedgerEntry> = scored
261            .iter()
262            .enumerate()
263            .map(|(rank, (id, score))| {
264                let v = &self.validators[*id];
265                LedgerEntry {
266                    id: *id,
267                    name: v.name.clone(),
268                    p: v.failure_prob(),
269                    c: v.cost_ema,
270                    score: *score,
271                    rank,
272                }
273            })
274            .collect();
275
276        (ordering, ledger)
277    }
278
279    /// Compute the expected cost of a given ordering.
280    ///
281    /// ```text
282    /// E[cost(π)] = Σ_k  c_{π_k} × Π_{j<k} (1 − p_{π_j})
283    /// ```
284    pub fn expected_cost(&self, ordering: &[usize]) -> f64 {
285        let mut survival = 1.0; // Π (1 - p_j) for validators seen so far
286        let mut total = 0.0;
287
288        for &id in ordering {
289            let v = &self.validators[id];
290            let c = v.cost_ema.max(self.config.c_min).as_secs_f64();
291            total += c * survival;
292            survival *= 1.0 - v.failure_prob();
293        }
294
295        total
296    }
297
298    /// Update a validator's statistics after observing an outcome.
299    pub fn update(&mut self, outcome: &ValidationOutcome) {
300        if let Some(v) = self.validators.get_mut(outcome.id) {
301            v.observations += 1;
302            if outcome.passed {
303                v.beta += 1.0;
304            } else {
305                v.alpha += 1.0;
306                v.failures += 1;
307            }
308            // EMA cost update
309            let gamma = self.config.gamma;
310            let old_ns = v.cost_ema.as_nanos() as f64;
311            let new_ns = outcome.duration.as_nanos() as f64;
312            let updated_ns = gamma * new_ns + (1.0 - gamma) * old_ns;
313            v.cost_ema =
314                Duration::from_nanos(updated_ns.max(self.config.c_min.as_nanos() as f64) as u64);
315        }
316    }
317
318    /// Update all validators from a pipeline result.
319    pub fn update_batch(&mut self, result: &PipelineResult) {
320        self.total_runs += 1;
321        for outcome in &result.outcomes {
322            self.update(outcome);
323        }
324    }
325
326    /// Simulate running the pipeline with provided validator functions.
327    ///
328    /// Each function in `validators` corresponds to a registered validator
329    /// (by index). Returns a `PipelineResult` with the optimal ordering applied.
330    pub fn run<F>(&self, mut validate: F) -> PipelineResult
331    where
332        F: FnMut(usize) -> (bool, Duration),
333    {
334        let (ordering, ledger) = self.compute_ordering();
335        let total_validators = ordering.len();
336        let mut outcomes = Vec::with_capacity(total_validators);
337        let mut total_cost = Duration::ZERO;
338        let mut all_passed = true;
339
340        for &id in &ordering {
341            let (passed, duration) = validate(id);
342            total_cost += duration;
343            outcomes.push(ValidationOutcome {
344                id,
345                passed,
346                duration,
347            });
348            if !passed {
349                all_passed = false;
350                break; // Early exit
351            }
352        }
353
354        let skipped = total_validators - outcomes.len();
355
356        PipelineResult {
357            all_passed,
358            outcomes,
359            total_cost,
360            ordering,
361            ledger,
362            skipped,
363        }
364    }
365
366    /// Get statistics for a validator by id.
367    pub fn stats(&self, id: usize) -> Option<&ValidatorStats> {
368        self.validators.get(id)
369    }
370
371    /// Get all validator stats.
372    pub fn all_stats(&self) -> &[ValidatorStats] {
373        &self.validators
374    }
375
376    /// Total pipeline runs.
377    pub fn total_runs(&self) -> u64 {
378        self.total_runs
379    }
380
381    /// Number of registered validators.
382    pub fn validator_count(&self) -> usize {
383        self.validators.len()
384    }
385
386    /// Summary of current state (for diagnostics).
387    pub fn summary(&self) -> PipelineSummary {
388        let (ordering, ledger) = self.compute_ordering();
389        let expected = self.expected_cost(&ordering);
390        // Compare against natural order for improvement metric.
391        let natural: Vec<usize> = (0..self.validators.len()).collect();
392        let natural_cost = self.expected_cost(&natural);
393        let improvement = if natural_cost > 0.0 {
394            1.0 - expected / natural_cost
395        } else {
396            0.0
397        };
398
399        PipelineSummary {
400            validator_count: self.validators.len(),
401            total_runs: self.total_runs,
402            optimal_ordering: ordering,
403            expected_cost_secs: expected,
404            natural_cost_secs: natural_cost,
405            improvement_fraction: improvement,
406            ledger,
407        }
408    }
409}
410
411impl Default for ValidationPipeline {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417/// Diagnostic summary of pipeline state.
418#[derive(Debug, Clone)]
419pub struct PipelineSummary {
420    pub validator_count: usize,
421    pub total_runs: u64,
422    pub optimal_ordering: Vec<usize>,
423    pub expected_cost_secs: f64,
424    pub natural_cost_secs: f64,
425    pub improvement_fraction: f64,
426    pub ledger: Vec<LedgerEntry>,
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    // ─── Unit tests ───────────────────────────────────────────────
434
435    #[test]
436    fn empty_pipeline_returns_success() {
437        let pipeline = ValidationPipeline::new();
438        let result = pipeline.run(|_| unreachable!());
439        assert!(result.all_passed);
440        assert!(result.outcomes.is_empty());
441        assert_eq!(result.total_cost, Duration::ZERO);
442        assert_eq!(result.skipped, 0);
443    }
444
445    #[test]
446    fn single_validator_pass() {
447        let mut pipeline = ValidationPipeline::new();
448        pipeline.register("check_a", Duration::from_millis(10));
449        let result = pipeline.run(|_| (true, Duration::from_millis(8)));
450        assert!(result.all_passed);
451        assert_eq!(result.outcomes.len(), 1);
452        assert_eq!(result.skipped, 0);
453    }
454
455    #[test]
456    fn single_validator_fail() {
457        let mut pipeline = ValidationPipeline::new();
458        pipeline.register("check_a", Duration::from_millis(10));
459        let result = pipeline.run(|_| (false, Duration::from_millis(5)));
460        assert!(!result.all_passed);
461        assert_eq!(result.outcomes.len(), 1);
462        assert!(!result.outcomes[0].passed);
463    }
464
465    #[test]
466    fn early_exit_on_failure() {
467        let mut pipeline = ValidationPipeline::new();
468        pipeline.register("cheap_fail", Duration::from_millis(1));
469        pipeline.register("expensive", Duration::from_millis(100));
470        pipeline.register("also_expensive", Duration::from_millis(50));
471
472        // Make cheap_fail have high failure rate to ensure it runs first.
473        for _ in 0..10 {
474            pipeline.update(&ValidationOutcome {
475                id: 0,
476                passed: false,
477                duration: Duration::from_millis(1),
478            });
479        }
480
481        let mut ran = Vec::new();
482        let result = pipeline.run(|id| {
483            ran.push(id);
484            if id == 0 {
485                (false, Duration::from_millis(1))
486            } else {
487                (true, Duration::from_millis(50))
488            }
489        });
490
491        assert!(!result.all_passed);
492        // Only the failing validator should have run (early exit).
493        assert_eq!(ran.len(), 1);
494        assert_eq!(ran[0], 0);
495        assert_eq!(result.skipped, 2);
496    }
497
498    #[test]
499    fn unit_expected_cost_formula() {
500        // Two validators: A(cost=10ms, p=0.8), B(cost=100ms, p=0.2)
501        // With uniform prior Beta(1,1), p starts at 0.5.
502        // We'll set explicit alpha/beta to get exact probabilities.
503        let mut pipeline = ValidationPipeline::new();
504        let a = pipeline.register("A", Duration::from_millis(10));
505        let b = pipeline.register("B", Duration::from_millis(100));
506
507        // Set A: p=0.8 → α=8, β=2 (plus prior α=1,β=1 already there)
508        for _ in 0..7 {
509            pipeline.update(&ValidationOutcome {
510                id: a,
511                passed: false,
512                duration: Duration::from_millis(10),
513            });
514        }
515        for _ in 0..1 {
516            pipeline.update(&ValidationOutcome {
517                id: a,
518                passed: true,
519                duration: Duration::from_millis(10),
520            });
521        }
522        // A now: α=1+7=8, β=1+1=2, p=8/10=0.8
523
524        // Set B: p=0.2 → α=2, β=8
525        for _ in 0..1 {
526            pipeline.update(&ValidationOutcome {
527                id: b,
528                passed: false,
529                duration: Duration::from_millis(100),
530            });
531        }
532        for _ in 0..7 {
533            pipeline.update(&ValidationOutcome {
534                id: b,
535                passed: true,
536                duration: Duration::from_millis(100),
537            });
538        }
539        // B now: α=1+1=2, β=1+7=8, p=2/10=0.2
540
541        let p_a = pipeline.stats(a).unwrap().failure_prob();
542        let p_b = pipeline.stats(b).unwrap().failure_prob();
543        assert!((p_a - 0.8).abs() < 1e-10);
544        assert!((p_b - 0.2).abs() < 1e-10);
545
546        // Order A,B: E = c_A + (1-p_A)*c_B = 10 + 0.2*100 = 30ms
547        let cost_ab = pipeline.expected_cost(&[a, b]);
548        let c_a = pipeline.stats(a).unwrap().cost_ema.as_secs_f64();
549        let c_b = pipeline.stats(b).unwrap().cost_ema.as_secs_f64();
550        let expected_ab = c_a + (1.0 - p_a) * c_b;
551        assert!((cost_ab - expected_ab).abs() < 1e-9);
552
553        // Order B,A: E = c_B + (1-p_B)*c_A = 100 + 0.8*10 = 108ms
554        let cost_ba = pipeline.expected_cost(&[b, a]);
555        let expected_ba = c_b + (1.0 - p_b) * c_a;
556        assert!((cost_ba - expected_ba).abs() < 1e-9);
557
558        // Optimal should prefer A first (lower expected cost).
559        assert!(cost_ab < cost_ba);
560    }
561
562    #[test]
563    fn zero_prior_defaults_to_uniform() {
564        let config = PipelineConfig {
565            prior_alpha: 0.0,
566            prior_beta: 0.0,
567            ..PipelineConfig::default()
568        };
569        let mut pipeline = ValidationPipeline::with_config(config);
570        pipeline.register("A", Duration::from_millis(10));
571        pipeline.register("B", Duration::from_millis(20));
572
573        let (ordering, ledger) = pipeline.compute_ordering();
574        assert_eq!(ordering.len(), 2);
575        assert_eq!(ledger.len(), 2);
576        for entry in ledger {
577            assert!(entry.p.is_finite());
578            assert!(entry.score.is_finite());
579            assert!((entry.p - 0.5).abs() < 1e-9);
580        }
581    }
582
583    #[test]
584    fn unit_posterior_update() {
585        let mut pipeline = ValidationPipeline::new();
586        let id = pipeline.register("v", Duration::from_millis(5));
587
588        // Prior: α=1, β=1, p=0.5
589        assert!((pipeline.stats(id).unwrap().failure_prob() - 0.5).abs() < 1e-10);
590
591        // Observe 3 failures
592        for _ in 0..3 {
593            pipeline.update(&ValidationOutcome {
594                id,
595                passed: false,
596                duration: Duration::from_millis(5),
597            });
598        }
599        // α=4, β=1, p=4/5=0.8
600        assert!((pipeline.stats(id).unwrap().failure_prob() - 0.8).abs() < 1e-10);
601
602        // Observe 4 successes
603        for _ in 0..4 {
604            pipeline.update(&ValidationOutcome {
605                id,
606                passed: true,
607                duration: Duration::from_millis(5),
608            });
609        }
610        // α=4, β=5, p=4/9≈0.444
611        assert!((pipeline.stats(id).unwrap().failure_prob() - 4.0 / 9.0).abs() < 1e-10);
612    }
613
614    #[test]
615    fn optimal_ordering_sorts_by_score() {
616        let mut pipeline = ValidationPipeline::new();
617        // A: cheap, low failure → low score
618        let a = pipeline.register("A_cheap_reliable", Duration::from_millis(1));
619        // B: expensive, high failure → medium score
620        let b = pipeline.register("B_expensive_flaky", Duration::from_millis(100));
621        // C: cheap, high failure → highest score
622        let c = pipeline.register("C_cheap_flaky", Duration::from_millis(1));
623
624        // Make B flaky
625        for _ in 0..8 {
626            pipeline.update(&ValidationOutcome {
627                id: b,
628                passed: false,
629                duration: Duration::from_millis(100),
630            });
631        }
632        // Make C flaky
633        for _ in 0..8 {
634            pipeline.update(&ValidationOutcome {
635                id: c,
636                passed: false,
637                duration: Duration::from_millis(1),
638            });
639        }
640        // Keep A reliable
641        for _ in 0..8 {
642            pipeline.update(&ValidationOutcome {
643                id: a,
644                passed: true,
645                duration: Duration::from_millis(1),
646            });
647        }
648
649        let (ordering, _ledger) = pipeline.compute_ordering();
650        // C should be first (cheap + flaky = highest p/c score: 0.9/1ms)
651        assert_eq!(ordering[0], c);
652        // A second (cheap + reliable: p=0.1 but c=1ms → score=100)
653        assert_eq!(ordering[1], a);
654        // B last (expensive + flaky: p=0.9 but c=100ms → score=9)
655        assert_eq!(ordering[2], b);
656    }
657
658    #[test]
659    fn cost_ema_updates() {
660        let mut pipeline = ValidationPipeline::with_config(PipelineConfig {
661            gamma: 0.5,
662            ..Default::default()
663        });
664        let id = pipeline.register("v", Duration::from_millis(10));
665
666        // Update with 20ms observation
667        pipeline.update(&ValidationOutcome {
668            id,
669            passed: true,
670            duration: Duration::from_millis(20),
671        });
672        // EMA: 0.5*20 + 0.5*10 = 15ms
673        let cost = pipeline.stats(id).unwrap().cost_ema;
674        assert!((cost.as_millis() as i64 - 15).abs() <= 1);
675
676        // Update with 30ms observation
677        pipeline.update(&ValidationOutcome {
678            id,
679            passed: true,
680            duration: Duration::from_millis(30),
681        });
682        // EMA: 0.5*30 + 0.5*15 = 22.5ms
683        let cost = pipeline.stats(id).unwrap().cost_ema;
684        assert!((cost.as_millis() as i64 - 22).abs() <= 1);
685    }
686
687    #[test]
688    fn cost_floor_prevents_zero() {
689        let mut pipeline = ValidationPipeline::new();
690        let id = pipeline.register("v", Duration::ZERO);
691        // Should be clamped to c_min.
692        let cost = pipeline.stats(id).unwrap().cost_ema;
693        assert!(cost >= C_MIN);
694    }
695
696    #[test]
697    fn ledger_records_all_validators() {
698        let mut pipeline = ValidationPipeline::new();
699        pipeline.register("a", Duration::from_millis(5));
700        pipeline.register("b", Duration::from_millis(10));
701        pipeline.register("c", Duration::from_millis(15));
702
703        let (_, ledger) = pipeline.compute_ordering();
704        assert_eq!(ledger.len(), 3);
705
706        // Each rank should be unique.
707        let mut ranks: Vec<usize> = ledger.iter().map(|e| e.rank).collect();
708        ranks.sort_unstable();
709        assert_eq!(ranks, vec![0, 1, 2]);
710    }
711
712    #[test]
713    fn deterministic_under_same_history() {
714        let run = || {
715            let mut p = ValidationPipeline::new();
716            p.register("x", Duration::from_millis(10));
717            p.register("y", Duration::from_millis(20));
718            p.register("z", Duration::from_millis(5));
719
720            // Fixed history
721            let history = [
722                (0, false, 10),
723                (1, true, 20),
724                (2, false, 5),
725                (0, true, 12),
726                (1, false, 18),
727                (2, true, 6),
728                (0, false, 9),
729                (1, true, 22),
730                (2, false, 4),
731            ];
732            for (id, passed, ms) in history {
733                p.update(&ValidationOutcome {
734                    id,
735                    passed,
736                    duration: Duration::from_millis(ms),
737                });
738            }
739
740            let (ordering, _) = p.compute_ordering();
741            let cost = p.expected_cost(&ordering);
742            (ordering, cost)
743        };
744
745        let (o1, c1) = run();
746        let (o2, c2) = run();
747        assert_eq!(o1, o2);
748        assert!((c1 - c2).abs() < 1e-15);
749    }
750
751    #[test]
752    fn summary_shows_improvement() {
753        let mut pipeline = ValidationPipeline::new();
754        // Register: cheap+flaky first, then expensive+reliable.
755        pipeline.register("expensive_reliable", Duration::from_millis(100));
756        pipeline.register("cheap_flaky", Duration::from_millis(1));
757
758        // Make id=1 very flaky.
759        for _ in 0..20 {
760            pipeline.update(&ValidationOutcome {
761                id: 1,
762                passed: false,
763                duration: Duration::from_millis(1),
764            });
765        }
766        // Make id=0 very reliable.
767        for _ in 0..20 {
768            pipeline.update(&ValidationOutcome {
769                id: 0,
770                passed: true,
771                duration: Duration::from_millis(100),
772            });
773        }
774
775        let summary = pipeline.summary();
776        // Optimal ordering should put cheap_flaky (id=1) first.
777        assert_eq!(summary.optimal_ordering[0], 1);
778        // Should show improvement (natural order is [0,1] which is worse).
779        assert!(summary.improvement_fraction > 0.0);
780    }
781
782    #[test]
783    fn variance_decreases_with_observations() {
784        let mut pipeline = ValidationPipeline::new();
785        let id = pipeline.register("v", Duration::from_millis(5));
786
787        let var_0 = pipeline.stats(id).unwrap().variance();
788
789        for _ in 0..10 {
790            pipeline.update(&ValidationOutcome {
791                id,
792                passed: false,
793                duration: Duration::from_millis(5),
794            });
795        }
796        let var_10 = pipeline.stats(id).unwrap().variance();
797
798        for _ in 0..90 {
799            pipeline.update(&ValidationOutcome {
800                id,
801                passed: false,
802                duration: Duration::from_millis(5),
803            });
804        }
805        let var_100 = pipeline.stats(id).unwrap().variance();
806
807        // Variance should decrease with more observations.
808        assert!(var_10 < var_0);
809        assert!(var_100 < var_10);
810    }
811
812    #[test]
813    fn confidence_width_contracts() {
814        let mut pipeline = ValidationPipeline::new();
815        let id = pipeline.register("v", Duration::from_millis(5));
816
817        let w0 = pipeline.stats(id).unwrap().confidence_width();
818
819        for _ in 0..50 {
820            pipeline.update(&ValidationOutcome {
821                id,
822                passed: true,
823                duration: Duration::from_millis(5),
824            });
825        }
826        let w50 = pipeline.stats(id).unwrap().confidence_width();
827
828        assert!(w50 < w0, "CI should narrow: w0={w0}, w50={w50}");
829    }
830
831    #[test]
832    fn update_batch_increments_total_runs() {
833        let mut pipeline = ValidationPipeline::new();
834        pipeline.register("v", Duration::from_millis(5));
835        assert_eq!(pipeline.total_runs(), 0);
836
837        let result = PipelineResult {
838            all_passed: true,
839            outcomes: vec![ValidationOutcome {
840                id: 0,
841                passed: true,
842                duration: Duration::from_millis(4),
843            }],
844            total_cost: Duration::from_millis(4),
845            ordering: vec![0],
846            ledger: Vec::new(),
847            skipped: 0,
848        };
849        pipeline.update_batch(&result);
850        assert_eq!(pipeline.total_runs(), 1);
851    }
852
853    // ─── Expected-cost brute-force verification for small n ───────
854
855    #[test]
856    fn expected_cost_matches_brute_force_n3() {
857        let mut pipeline = ValidationPipeline::new();
858        pipeline.register("a", Duration::from_millis(10));
859        pipeline.register("b", Duration::from_millis(20));
860        pipeline.register("c", Duration::from_millis(5));
861
862        // Set distinct failure probs.
863        // a: 3 failures → α=4, β=1, p=0.8
864        for _ in 0..3 {
865            pipeline.update(&ValidationOutcome {
866                id: 0,
867                passed: false,
868                duration: Duration::from_millis(10),
869            });
870        }
871        // b: 1 failure, 3 success → α=2, β=4, p=1/3
872        pipeline.update(&ValidationOutcome {
873            id: 1,
874            passed: false,
875            duration: Duration::from_millis(20),
876        });
877        for _ in 0..3 {
878            pipeline.update(&ValidationOutcome {
879                id: 1,
880                passed: true,
881                duration: Duration::from_millis(20),
882            });
883        }
884        // c: 2 failures, 1 success → α=3, β=2, p=0.6
885        for _ in 0..2 {
886            pipeline.update(&ValidationOutcome {
887                id: 2,
888                passed: false,
889                duration: Duration::from_millis(5),
890            });
891        }
892        pipeline.update(&ValidationOutcome {
893            id: 2,
894            passed: true,
895            duration: Duration::from_millis(5),
896        });
897
898        // Brute-force: try all 6 permutations.
899        let perms: &[&[usize]] = &[
900            &[0, 1, 2],
901            &[0, 2, 1],
902            &[1, 0, 2],
903            &[1, 2, 0],
904            &[2, 0, 1],
905            &[2, 1, 0],
906        ];
907        let mut best_cost = f64::MAX;
908        let mut best_perm = &[0usize, 1, 2][..];
909        for perm in perms {
910            let cost = pipeline.expected_cost(perm);
911            if cost < best_cost {
912                best_cost = cost;
913                best_perm = perm;
914            }
915        }
916
917        // Our optimal ordering should match.
918        let (optimal, _) = pipeline.compute_ordering();
919        let optimal_cost = pipeline.expected_cost(&optimal);
920
921        assert!(
922            (optimal_cost - best_cost).abs() < 1e-12,
923            "optimal={optimal_cost}, brute_force={best_cost}, best_perm={best_perm:?}, our={optimal:?}"
924        );
925    }
926
927    // ─── Performance overhead test ────────────────────────────────
928
929    #[test]
930    fn perf_ordering_overhead() {
931        let mut pipeline = ValidationPipeline::new();
932        // Register 100 validators.
933        for i in 0..100 {
934            pipeline.register(format!("v{i}"), Duration::from_micros(100 + i as u64 * 10));
935        }
936        // Feed some history.
937        for i in 0..100 {
938            for _ in 0..5 {
939                pipeline.update(&ValidationOutcome {
940                    id: i,
941                    passed: i % 3 != 0,
942                    duration: Duration::from_micros(100 + i as u64 * 10),
943                });
944            }
945        }
946
947        let start = web_time::Instant::now();
948        for _ in 0..1000 {
949            let _ = pipeline.compute_ordering();
950        }
951        let elapsed = start.elapsed();
952        // 1000 orderings of 100 validators should be < 100ms.
953        assert!(
954            elapsed < Duration::from_millis(100),
955            "ordering overhead too high: {elapsed:?} for 1000 iterations"
956        );
957    }
958
959    #[test]
960    fn pipeline_config_default_values() {
961        let config = PipelineConfig::default();
962        assert!((config.prior_alpha - 1.0).abs() < 1e-10);
963        assert!((config.prior_beta - 1.0).abs() < 1e-10);
964        assert!((config.gamma - DEFAULT_GAMMA).abs() < 1e-10);
965        assert_eq!(config.c_min, C_MIN);
966    }
967
968    #[test]
969    fn pipeline_default_impl() {
970        let p = ValidationPipeline::default();
971        assert_eq!(p.validator_count(), 0);
972        assert_eq!(p.total_runs(), 0);
973    }
974
975    #[test]
976    fn all_stats_returns_all_registered() {
977        let mut pipeline = ValidationPipeline::new();
978        pipeline.register("a", Duration::from_millis(5));
979        pipeline.register("b", Duration::from_millis(10));
980        pipeline.register("c", Duration::from_millis(15));
981        let stats = pipeline.all_stats();
982        assert_eq!(stats.len(), 3);
983        assert_eq!(stats[0].name, "a");
984        assert_eq!(stats[1].name, "b");
985        assert_eq!(stats[2].name, "c");
986    }
987
988    #[test]
989    fn stats_invalid_id_returns_none() {
990        let pipeline = ValidationPipeline::new();
991        assert!(pipeline.stats(0).is_none());
992        assert!(pipeline.stats(999).is_none());
993    }
994
995    #[test]
996    fn update_invalid_id_is_noop() {
997        let mut pipeline = ValidationPipeline::new();
998        pipeline.register("v", Duration::from_millis(5));
999        pipeline.update(&ValidationOutcome {
1000            id: 99,
1001            passed: false,
1002            duration: Duration::from_millis(5),
1003        });
1004        // Should not panic, and valid validator unchanged
1005        assert_eq!(pipeline.stats(0).unwrap().observations, 0);
1006    }
1007
1008    #[test]
1009    fn failure_prob_zero_sum_returns_half() {
1010        let config = PipelineConfig {
1011            prior_alpha: 0.0,
1012            prior_beta: 0.0,
1013            ..Default::default()
1014        };
1015        let mut pipeline = ValidationPipeline::with_config(config);
1016        let id = pipeline.register("v", Duration::from_millis(5));
1017        let p = pipeline.stats(id).unwrap().failure_prob();
1018        assert!((p - 0.5).abs() < 1e-10);
1019    }
1020
1021    #[test]
1022    fn variance_zero_sum_returns_uniform() {
1023        let config = PipelineConfig {
1024            prior_alpha: 0.0,
1025            prior_beta: 0.0,
1026            ..Default::default()
1027        };
1028        let mut pipeline = ValidationPipeline::with_config(config);
1029        let id = pipeline.register("v", Duration::from_millis(5));
1030        let var = pipeline.stats(id).unwrap().variance();
1031        assert!((var - 1.0 / 12.0).abs() < 1e-10);
1032    }
1033
1034    #[test]
1035    fn score_uses_cost_floor() {
1036        let mut pipeline = ValidationPipeline::new();
1037        let id = pipeline.register("v", Duration::ZERO);
1038        let score = pipeline.stats(id).unwrap().score(C_MIN);
1039        assert!(score.is_finite());
1040        assert!(score > 0.0);
1041    }
1042
1043    #[test]
1044    fn summary_empty_pipeline() {
1045        let pipeline = ValidationPipeline::new();
1046        let summary = pipeline.summary();
1047        assert_eq!(summary.validator_count, 0);
1048        assert_eq!(summary.total_runs, 0);
1049        assert!(summary.optimal_ordering.is_empty());
1050        assert!((summary.expected_cost_secs).abs() < 1e-10);
1051        assert!((summary.improvement_fraction).abs() < 1e-10);
1052    }
1053
1054    #[test]
1055    fn register_returns_sequential_ids() {
1056        let mut pipeline = ValidationPipeline::new();
1057        let id0 = pipeline.register("first", Duration::from_millis(1));
1058        let id1 = pipeline.register("second", Duration::from_millis(2));
1059        let id2 = pipeline.register("third", Duration::from_millis(3));
1060        assert_eq!(id0, 0);
1061        assert_eq!(id1, 1);
1062        assert_eq!(id2, 2);
1063    }
1064
1065    // ─── Edge-case tests (bd-1bv8r) ─────────────────────────────
1066
1067    #[test]
1068    fn run_all_pass_multi_validator() {
1069        let mut pipeline = ValidationPipeline::new();
1070        pipeline.register("a", Duration::from_millis(5));
1071        pipeline.register("b", Duration::from_millis(10));
1072        pipeline.register("c", Duration::from_millis(15));
1073
1074        let result = pipeline.run(|_| (true, Duration::from_millis(7)));
1075        assert!(result.all_passed);
1076        assert_eq!(result.outcomes.len(), 3, "all validators should run");
1077        assert_eq!(result.skipped, 0);
1078        assert!(result.outcomes.iter().all(|o| o.passed));
1079    }
1080
1081    #[test]
1082    fn run_failure_at_second_position() {
1083        let mut pipeline = ValidationPipeline::new();
1084        pipeline.register("a", Duration::from_millis(5));
1085        pipeline.register("b", Duration::from_millis(10));
1086        pipeline.register("c", Duration::from_millis(15));
1087
1088        // With uniform priors and costs 5,10,15, ordering is by p/c.
1089        // All have same p=0.5, so ordering is by 1/c → cheapest first: a(0), b(1), c(2).
1090        let result = pipeline.run(|id| {
1091            if id == 1 {
1092                (false, Duration::from_millis(10))
1093            } else {
1094                (true, Duration::from_millis(5))
1095            }
1096        });
1097        assert!(!result.all_passed);
1098        // a passes, b fails → c skipped
1099        assert_eq!(result.outcomes.len(), 2);
1100        assert!(result.outcomes[0].passed);
1101        assert!(!result.outcomes[1].passed);
1102        assert_eq!(result.skipped, 1);
1103    }
1104
1105    #[test]
1106    fn ema_gamma_one_full_replacement() {
1107        let mut pipeline = ValidationPipeline::with_config(PipelineConfig {
1108            gamma: 1.0,
1109            ..Default::default()
1110        });
1111        let id = pipeline.register("v", Duration::from_millis(100));
1112
1113        pipeline.update(&ValidationOutcome {
1114            id,
1115            passed: true,
1116            duration: Duration::from_millis(50),
1117        });
1118        // gamma=1.0 → c_new = 1.0*50 + 0.0*100 = 50ms
1119        let cost = pipeline.stats(id).unwrap().cost_ema;
1120        assert_eq!(cost.as_millis(), 50);
1121    }
1122
1123    #[test]
1124    fn ema_gamma_near_zero_minimal_update() {
1125        let mut pipeline = ValidationPipeline::with_config(PipelineConfig {
1126            gamma: 0.01,
1127            ..Default::default()
1128        });
1129        let id = pipeline.register("v", Duration::from_millis(100));
1130
1131        pipeline.update(&ValidationOutcome {
1132            id,
1133            passed: true,
1134            duration: Duration::from_millis(200),
1135        });
1136        // gamma=0.01 → c_new = 0.01*200 + 0.99*100 = 2 + 99 = 101ms
1137        let cost = pipeline.stats(id).unwrap().cost_ema;
1138        assert!(
1139            (cost.as_millis() as i64 - 101).abs() <= 1,
1140            "cost should barely move: got {}ms",
1141            cost.as_millis()
1142        );
1143    }
1144
1145    #[test]
1146    fn cost_ema_floor_during_update() {
1147        let mut pipeline = ValidationPipeline::new();
1148        let id = pipeline.register("v", Duration::from_millis(10));
1149
1150        // Observe zero-duration → EMA should clamp to c_min
1151        pipeline.update(&ValidationOutcome {
1152            id,
1153            passed: true,
1154            duration: Duration::ZERO,
1155        });
1156        let cost = pipeline.stats(id).unwrap().cost_ema;
1157        assert!(
1158            cost >= C_MIN,
1159            "cost should be floored to c_min, got {:?}",
1160            cost
1161        );
1162    }
1163
1164    #[test]
1165    fn ordering_tie_break_by_id() {
1166        // Two validators with identical cost and identical priors → same score.
1167        // Tie should be broken by lower id first.
1168        let mut pipeline = ValidationPipeline::new();
1169        pipeline.register("second", Duration::from_millis(10));
1170        pipeline.register("first", Duration::from_millis(10));
1171
1172        let (ordering, _) = pipeline.compute_ordering();
1173        assert_eq!(
1174            ordering,
1175            vec![0, 1],
1176            "identical scores should tie-break by lower id first"
1177        );
1178    }
1179
1180    #[test]
1181    fn ordering_tie_break_three_way() {
1182        let mut pipeline = ValidationPipeline::new();
1183        pipeline.register("c", Duration::from_millis(5));
1184        pipeline.register("a", Duration::from_millis(5));
1185        pipeline.register("b", Duration::from_millis(5));
1186
1187        // All same cost, same prior → same score → order by id
1188        let (ordering, _) = pipeline.compute_ordering();
1189        assert_eq!(ordering, vec![0, 1, 2]);
1190    }
1191
1192    #[test]
1193    fn expected_cost_single_validator() {
1194        let mut pipeline = ValidationPipeline::new();
1195        pipeline.register("v", Duration::from_millis(10));
1196
1197        let cost = pipeline.expected_cost(&[0]);
1198        // Single validator: E = c_0 * 1.0 (survival starts at 1)
1199        let c = pipeline.stats(0).unwrap().cost_ema.as_secs_f64();
1200        assert!((cost - c).abs() < 1e-12);
1201    }
1202
1203    #[test]
1204    fn expected_cost_empty_ordering() {
1205        let mut pipeline = ValidationPipeline::new();
1206        pipeline.register("v", Duration::from_millis(10));
1207
1208        // Empty ordering → zero cost
1209        let cost = pipeline.expected_cost(&[]);
1210        assert!((cost).abs() < 1e-15);
1211    }
1212
1213    #[test]
1214    fn summary_single_validator() {
1215        let mut pipeline = ValidationPipeline::new();
1216        pipeline.register("v", Duration::from_millis(10));
1217
1218        let summary = pipeline.summary();
1219        assert_eq!(summary.validator_count, 1);
1220        assert_eq!(summary.optimal_ordering, vec![0]);
1221        // Natural order = optimal order → improvement = 0
1222        assert!(
1223            summary.improvement_fraction.abs() < 1e-10,
1224            "single validator can't improve: got {}",
1225            summary.improvement_fraction
1226        );
1227    }
1228
1229    #[test]
1230    fn summary_identical_validators_no_improvement() {
1231        let mut pipeline = ValidationPipeline::new();
1232        pipeline.register("a", Duration::from_millis(10));
1233        pipeline.register("b", Duration::from_millis(10));
1234
1235        // Same cost, same priors → no improvement possible
1236        let summary = pipeline.summary();
1237        assert!(
1238            summary.improvement_fraction.abs() < 1e-10,
1239            "identical validators should have zero improvement"
1240        );
1241    }
1242
1243    #[test]
1244    fn observations_and_failures_counters() {
1245        let mut pipeline = ValidationPipeline::new();
1246        let id = pipeline.register("v", Duration::from_millis(5));
1247
1248        assert_eq!(pipeline.stats(id).unwrap().observations, 0);
1249        assert_eq!(pipeline.stats(id).unwrap().failures, 0);
1250
1251        // 3 failures
1252        for _ in 0..3 {
1253            pipeline.update(&ValidationOutcome {
1254                id,
1255                passed: false,
1256                duration: Duration::from_millis(5),
1257            });
1258        }
1259        assert_eq!(pipeline.stats(id).unwrap().observations, 3);
1260        assert_eq!(pipeline.stats(id).unwrap().failures, 3);
1261
1262        // 2 successes
1263        for _ in 0..2 {
1264            pipeline.update(&ValidationOutcome {
1265                id,
1266                passed: true,
1267                duration: Duration::from_millis(5),
1268            });
1269        }
1270        assert_eq!(pipeline.stats(id).unwrap().observations, 5);
1271        assert_eq!(pipeline.stats(id).unwrap().failures, 3);
1272    }
1273
1274    #[test]
1275    fn update_batch_multiple_calls_increment_total_runs() {
1276        let mut pipeline = ValidationPipeline::new();
1277        pipeline.register("v", Duration::from_millis(5));
1278
1279        let result = PipelineResult {
1280            all_passed: true,
1281            outcomes: vec![ValidationOutcome {
1282                id: 0,
1283                passed: true,
1284                duration: Duration::from_millis(4),
1285            }],
1286            total_cost: Duration::from_millis(4),
1287            ordering: vec![0],
1288            ledger: Vec::new(),
1289            skipped: 0,
1290        };
1291
1292        pipeline.update_batch(&result);
1293        pipeline.update_batch(&result);
1294        pipeline.update_batch(&result);
1295        assert_eq!(pipeline.total_runs(), 3);
1296    }
1297
1298    #[test]
1299    fn update_batch_empty_outcomes_still_increments() {
1300        let mut pipeline = ValidationPipeline::new();
1301        pipeline.register("v", Duration::from_millis(5));
1302
1303        let result = PipelineResult {
1304            all_passed: true,
1305            outcomes: Vec::new(),
1306            total_cost: Duration::ZERO,
1307            ordering: vec![0],
1308            ledger: Vec::new(),
1309            skipped: 1,
1310        };
1311
1312        pipeline.update_batch(&result);
1313        assert_eq!(pipeline.total_runs(), 1);
1314        // Validator stats unchanged
1315        assert_eq!(pipeline.stats(0).unwrap().observations, 0);
1316    }
1317
1318    #[test]
1319    fn run_then_update_batch_round_trip() {
1320        let mut pipeline = ValidationPipeline::new();
1321        pipeline.register("fast", Duration::from_millis(1));
1322        pipeline.register("slow", Duration::from_millis(100));
1323
1324        let result = pipeline.run(|id| {
1325            if id == 0 {
1326                (true, Duration::from_millis(2))
1327            } else {
1328                (true, Duration::from_millis(80))
1329            }
1330        });
1331        assert!(result.all_passed);
1332
1333        pipeline.update_batch(&result);
1334        assert_eq!(pipeline.total_runs(), 1);
1335
1336        // Both validators should have 1 observation
1337        assert_eq!(pipeline.stats(0).unwrap().observations, 1);
1338        assert_eq!(pipeline.stats(1).unwrap().observations, 1);
1339        // Both should have updated beta (success)
1340        assert!(
1341            pipeline.stats(0).unwrap().beta > 1.0,
1342            "beta should increase on success"
1343        );
1344    }
1345
1346    #[test]
1347    fn run_then_update_batch_with_early_exit() {
1348        let mut pipeline = ValidationPipeline::new();
1349        pipeline.register("a", Duration::from_millis(5));
1350        pipeline.register("b", Duration::from_millis(10));
1351
1352        let result = pipeline.run(|id| {
1353            if id == 0 {
1354                (false, Duration::from_millis(3))
1355            } else {
1356                (true, Duration::from_millis(8))
1357            }
1358        });
1359        assert!(!result.all_passed);
1360
1361        pipeline.update_batch(&result);
1362        // Only first validator ran (early exit), so only it gets updated
1363        let ran_id = result.outcomes[0].id;
1364        assert_eq!(pipeline.stats(ran_id).unwrap().observations, 1);
1365
1366        // The skipped validator should still have 0 observations
1367        let skipped_count: u64 = pipeline
1368            .all_stats()
1369            .iter()
1370            .filter(|s| s.observations == 0)
1371            .count() as u64;
1372        assert_eq!(skipped_count, 1, "one validator was skipped");
1373    }
1374
1375    #[test]
1376    fn confidence_width_always_positive() {
1377        let mut pipeline = ValidationPipeline::new();
1378        let id = pipeline.register("v", Duration::from_millis(5));
1379
1380        // Before any observations
1381        let w = pipeline.stats(id).unwrap().confidence_width();
1382        assert!(w > 0.0, "confidence_width should be positive: {w}");
1383
1384        // After observations
1385        for _ in 0..10 {
1386            pipeline.update(&ValidationOutcome {
1387                id,
1388                passed: true,
1389                duration: Duration::from_millis(5),
1390            });
1391        }
1392        let w2 = pipeline.stats(id).unwrap().confidence_width();
1393        assert!(w2 > 0.0, "confidence_width should be positive: {w2}");
1394    }
1395
1396    #[test]
1397    fn variance_known_values() {
1398        let mut pipeline = ValidationPipeline::new();
1399        let id = pipeline.register("v", Duration::from_millis(5));
1400
1401        // Prior Beta(1,1): variance = 1*1 / (4*3) = 1/12
1402        let var = pipeline.stats(id).unwrap().variance();
1403        assert!(
1404            (var - 1.0 / 12.0).abs() < 1e-10,
1405            "Beta(1,1) variance should be 1/12"
1406        );
1407
1408        // After 3 failures: Beta(4,1), variance = 4*1 / (25*6) = 4/150
1409        for _ in 0..3 {
1410            pipeline.update(&ValidationOutcome {
1411                id,
1412                passed: false,
1413                duration: Duration::from_millis(5),
1414            });
1415        }
1416        let var2 = pipeline.stats(id).unwrap().variance();
1417        let expected = 4.0 * 1.0 / (25.0 * 6.0);
1418        assert!(
1419            (var2 - expected).abs() < 1e-10,
1420            "Beta(4,1) variance: expected {expected}, got {var2}"
1421        );
1422    }
1423
1424    #[test]
1425    fn strong_prior_dominates() {
1426        let config = PipelineConfig {
1427            prior_alpha: 100.0,
1428            prior_beta: 100.0,
1429            ..Default::default()
1430        };
1431        let mut pipeline = ValidationPipeline::with_config(config);
1432        let id = pipeline.register("v", Duration::from_millis(5));
1433
1434        // Prior: p = 100/200 = 0.5
1435        // After 5 failures: p = 105/205 ≈ 0.5122 (barely moves)
1436        for _ in 0..5 {
1437            pipeline.update(&ValidationOutcome {
1438                id,
1439                passed: false,
1440                duration: Duration::from_millis(5),
1441            });
1442        }
1443        let p = pipeline.stats(id).unwrap().failure_prob();
1444        assert!(
1445            (p - 105.0 / 205.0).abs() < 1e-10,
1446            "strong prior should dominate: got {p}"
1447        );
1448    }
1449
1450    #[test]
1451    fn register_empty_name() {
1452        let mut pipeline = ValidationPipeline::new();
1453        let id = pipeline.register("", Duration::from_millis(5));
1454        assert_eq!(pipeline.stats(id).unwrap().name, "");
1455    }
1456
1457    #[test]
1458    fn expected_cost_many_validators_survival_shrinks() {
1459        let mut pipeline = ValidationPipeline::new();
1460        // 10 validators each with p=0.5 (uniform prior), cost=10ms
1461        for i in 0..10 {
1462            pipeline.register(format!("v{i}"), Duration::from_millis(10));
1463        }
1464
1465        let ordering: Vec<usize> = (0..10).collect();
1466        let cost = pipeline.expected_cost(&ordering);
1467
1468        // E = 10*(1 + 0.5 + 0.25 + ... + 0.5^9) = 10 * (1 - 0.5^10) / (1 - 0.5)
1469        // = 10 * (1 - 1/1024) / 0.5 = 10 * 1023/1024 / 0.5 ≈ 19.98
1470        let c = 0.010; // 10ms in seconds
1471        let geometric_sum: f64 = (0..10).map(|k| 0.5_f64.powi(k)).sum();
1472        let expected = c * geometric_sum;
1473        assert!(
1474            (cost - expected).abs() < 1e-10,
1475            "expected {expected}, got {cost}"
1476        );
1477    }
1478
1479    #[test]
1480    fn score_with_very_large_cost() {
1481        let mut pipeline = ValidationPipeline::new();
1482        let id = pipeline.register("v", Duration::from_secs(1_000_000));
1483        let score = pipeline.stats(id).unwrap().score(C_MIN);
1484        assert!(score.is_finite());
1485        assert!(score > 0.0);
1486        assert!(score < 1.0, "score with huge cost should be tiny");
1487    }
1488
1489    #[test]
1490    fn score_with_cost_at_c_min() {
1491        let mut pipeline = ValidationPipeline::new();
1492        let id = pipeline.register("v", C_MIN);
1493        let score = pipeline.stats(id).unwrap().score(C_MIN);
1494        assert!(score.is_finite());
1495        // p=0.5, c=1μs → score = 0.5/0.000001 = 500000
1496        assert!(score > 1.0, "score with c_min cost should be large");
1497    }
1498
1499    #[test]
1500    fn ledger_entry_fields_match_stats() {
1501        let mut pipeline = ValidationPipeline::new();
1502        pipeline.register("v", Duration::from_millis(10));
1503
1504        // Add some observations
1505        for _ in 0..5 {
1506            pipeline.update(&ValidationOutcome {
1507                id: 0,
1508                passed: false,
1509                duration: Duration::from_millis(10),
1510            });
1511        }
1512
1513        let (_, ledger) = pipeline.compute_ordering();
1514        assert_eq!(ledger.len(), 1);
1515        let entry = &ledger[0];
1516        let stats = pipeline.stats(0).unwrap();
1517
1518        assert_eq!(entry.id, stats.id);
1519        assert_eq!(entry.name, stats.name);
1520        assert!((entry.p - stats.failure_prob()).abs() < 1e-10);
1521        assert_eq!(entry.c, stats.cost_ema);
1522        assert!((entry.score - stats.score(C_MIN)).abs() < 1e-10);
1523        assert_eq!(entry.rank, 0);
1524    }
1525
1526    #[test]
1527    fn run_closure_called_in_ordering_sequence() {
1528        let mut pipeline = ValidationPipeline::new();
1529        pipeline.register("expensive", Duration::from_millis(100));
1530        pipeline.register("cheap", Duration::from_millis(1));
1531
1532        // cheap (id=1) should run first due to higher p/c score
1533        let mut call_order = Vec::new();
1534        let _result = pipeline.run(|id| {
1535            call_order.push(id);
1536            (true, Duration::from_millis(5))
1537        });
1538
1539        let (expected_ordering, _) = pipeline.compute_ordering();
1540        assert_eq!(
1541            call_order, expected_ordering,
1542            "closure should be called in ordering sequence"
1543        );
1544    }
1545
1546    #[test]
1547    fn pipeline_result_ordering_matches_compute() {
1548        let mut pipeline = ValidationPipeline::new();
1549        pipeline.register("a", Duration::from_millis(5));
1550        pipeline.register("b", Duration::from_millis(50));
1551        pipeline.register("c", Duration::from_millis(1));
1552
1553        let (expected_ordering, _) = pipeline.compute_ordering();
1554        let result = pipeline.run(|_| (true, Duration::from_millis(3)));
1555        assert_eq!(result.ordering, expected_ordering);
1556    }
1557
1558    #[test]
1559    fn update_batch_applies_to_all_outcomes() {
1560        let mut pipeline = ValidationPipeline::new();
1561        pipeline.register("a", Duration::from_millis(5));
1562        pipeline.register("b", Duration::from_millis(10));
1563
1564        let result = PipelineResult {
1565            all_passed: true,
1566            outcomes: vec![
1567                ValidationOutcome {
1568                    id: 0,
1569                    passed: true,
1570                    duration: Duration::from_millis(4),
1571                },
1572                ValidationOutcome {
1573                    id: 1,
1574                    passed: false,
1575                    duration: Duration::from_millis(8),
1576                },
1577            ],
1578            total_cost: Duration::from_millis(12),
1579            ordering: vec![0, 1],
1580            ledger: Vec::new(),
1581            skipped: 0,
1582        };
1583
1584        pipeline.update_batch(&result);
1585        assert_eq!(pipeline.stats(0).unwrap().observations, 1);
1586        assert_eq!(pipeline.stats(0).unwrap().failures, 0);
1587        assert_eq!(pipeline.stats(1).unwrap().observations, 1);
1588        assert_eq!(pipeline.stats(1).unwrap().failures, 1);
1589    }
1590
1591    #[test]
1592    fn multiple_pipelines_independent() {
1593        let mut p1 = ValidationPipeline::new();
1594        let mut p2 = ValidationPipeline::new();
1595
1596        p1.register("v", Duration::from_millis(5));
1597        p2.register("v", Duration::from_millis(5));
1598
1599        for _ in 0..10 {
1600            p1.update(&ValidationOutcome {
1601                id: 0,
1602                passed: false,
1603                duration: Duration::from_millis(5),
1604            });
1605        }
1606
1607        // p2 should be unaffected
1608        assert_eq!(p2.stats(0).unwrap().observations, 0);
1609        assert_eq!(p1.stats(0).unwrap().observations, 10);
1610    }
1611
1612    #[test]
1613    fn pipeline_clone_independent() {
1614        let mut original = ValidationPipeline::new();
1615        original.register("v", Duration::from_millis(5));
1616
1617        let mut cloned = original.clone();
1618
1619        // Mutate clone
1620        cloned.update(&ValidationOutcome {
1621            id: 0,
1622            passed: false,
1623            duration: Duration::from_millis(5),
1624        });
1625
1626        // Original unchanged
1627        assert_eq!(original.stats(0).unwrap().observations, 0);
1628        assert_eq!(cloned.stats(0).unwrap().observations, 1);
1629    }
1630
1631    #[test]
1632    fn pipeline_config_clone() {
1633        let config = PipelineConfig {
1634            prior_alpha: 2.0,
1635            prior_beta: 3.0,
1636            gamma: 0.5,
1637            c_min: Duration::from_micros(10),
1638        };
1639        let cloned = config.clone();
1640        assert!((cloned.prior_alpha - 2.0).abs() < 1e-10);
1641        assert!((cloned.prior_beta - 3.0).abs() < 1e-10);
1642        assert!((cloned.gamma - 0.5).abs() < 1e-10);
1643        assert_eq!(cloned.c_min, Duration::from_micros(10));
1644    }
1645
1646    #[test]
1647    fn validator_stats_clone() {
1648        let mut pipeline = ValidationPipeline::new();
1649        let id = pipeline.register("v", Duration::from_millis(5));
1650        for _ in 0..3 {
1651            pipeline.update(&ValidationOutcome {
1652                id,
1653                passed: false,
1654                duration: Duration::from_millis(5),
1655            });
1656        }
1657        let stats = pipeline.stats(id).unwrap().clone();
1658        assert_eq!(stats.observations, 3);
1659        assert_eq!(stats.failures, 3);
1660        assert_eq!(stats.name, "v");
1661    }
1662
1663    #[test]
1664    fn debug_formatting_pipeline_result() {
1665        let result = PipelineResult {
1666            all_passed: true,
1667            outcomes: Vec::new(),
1668            total_cost: Duration::ZERO,
1669            ordering: vec![0],
1670            ledger: Vec::new(),
1671            skipped: 0,
1672        };
1673        let debug = format!("{result:?}");
1674        assert!(debug.contains("PipelineResult"));
1675        assert!(debug.contains("all_passed: true"));
1676    }
1677
1678    #[test]
1679    fn debug_formatting_validation_outcome() {
1680        let outcome = ValidationOutcome {
1681            id: 42,
1682            passed: false,
1683            duration: Duration::from_millis(10),
1684        };
1685        let debug = format!("{outcome:?}");
1686        assert!(debug.contains("ValidationOutcome"));
1687        assert!(debug.contains("42"));
1688        assert!(debug.contains("false"));
1689    }
1690
1691    #[test]
1692    fn debug_formatting_ledger_entry() {
1693        let entry = LedgerEntry {
1694            id: 7,
1695            name: "test_validator".to_string(),
1696            p: 0.75,
1697            c: Duration::from_millis(10),
1698            score: 75.0,
1699            rank: 0,
1700        };
1701        let debug = format!("{entry:?}");
1702        assert!(debug.contains("LedgerEntry"));
1703        assert!(debug.contains("test_validator"));
1704    }
1705
1706    #[test]
1707    fn debug_formatting_pipeline_summary() {
1708        let summary = PipelineSummary {
1709            validator_count: 2,
1710            total_runs: 5,
1711            optimal_ordering: vec![1, 0],
1712            expected_cost_secs: 0.015,
1713            natural_cost_secs: 0.020,
1714            improvement_fraction: 0.25,
1715            ledger: Vec::new(),
1716        };
1717        let debug = format!("{summary:?}");
1718        assert!(debug.contains("PipelineSummary"));
1719        assert!(debug.contains("validator_count: 2"));
1720    }
1721
1722    #[test]
1723    fn debug_formatting_pipeline_config() {
1724        let config = PipelineConfig::default();
1725        let debug = format!("{config:?}");
1726        assert!(debug.contains("PipelineConfig"));
1727        assert!(debug.contains("prior_alpha"));
1728    }
1729
1730    #[test]
1731    fn debug_formatting_validator_stats() {
1732        let mut pipeline = ValidationPipeline::new();
1733        let id = pipeline.register("test_v", Duration::from_millis(5));
1734        let stats = pipeline.stats(id).unwrap();
1735        let debug = format!("{stats:?}");
1736        assert!(debug.contains("ValidatorStats"));
1737        assert!(debug.contains("test_v"));
1738    }
1739
1740    #[test]
1741    fn debug_formatting_validation_pipeline() {
1742        let mut pipeline = ValidationPipeline::new();
1743        pipeline.register("v", Duration::from_millis(5));
1744        let debug = format!("{pipeline:?}");
1745        assert!(debug.contains("ValidationPipeline"));
1746    }
1747
1748    #[test]
1749    fn with_config_custom_prior() {
1750        let config = PipelineConfig {
1751            prior_alpha: 5.0,
1752            prior_beta: 10.0,
1753            ..Default::default()
1754        };
1755        let mut pipeline = ValidationPipeline::with_config(config);
1756        let id = pipeline.register("v", Duration::from_millis(5));
1757
1758        // p = 5/(5+10) = 1/3
1759        let p = pipeline.stats(id).unwrap().failure_prob();
1760        assert!(
1761            (p - 1.0 / 3.0).abs() < 1e-10,
1762            "custom prior should set initial p: got {p}"
1763        );
1764    }
1765
1766    #[test]
1767    fn with_config_custom_c_min() {
1768        let config = PipelineConfig {
1769            c_min: Duration::from_millis(10),
1770            ..Default::default()
1771        };
1772        let mut pipeline = ValidationPipeline::with_config(config);
1773        let id = pipeline.register("v", Duration::from_millis(1));
1774
1775        // Cost should be clamped to custom c_min=10ms
1776        let cost = pipeline.stats(id).unwrap().cost_ema;
1777        assert!(
1778            cost >= Duration::from_millis(10),
1779            "cost should be clamped to c_min: got {:?}",
1780            cost
1781        );
1782    }
1783
1784    #[test]
1785    fn update_does_not_increment_total_runs() {
1786        let mut pipeline = ValidationPipeline::new();
1787        pipeline.register("v", Duration::from_millis(5));
1788
1789        pipeline.update(&ValidationOutcome {
1790            id: 0,
1791            passed: true,
1792            duration: Duration::from_millis(5),
1793        });
1794        // update (not update_batch) should NOT increment total_runs
1795        assert_eq!(
1796            pipeline.total_runs(),
1797            0,
1798            "update() should not increment total_runs"
1799        );
1800    }
1801
1802    #[test]
1803    fn ordering_reverses_after_learning() {
1804        let mut pipeline = ValidationPipeline::new();
1805        let a = pipeline.register("a", Duration::from_millis(10));
1806        let b = pipeline.register("b", Duration::from_millis(10));
1807
1808        // Initially both same → order by id: [0, 1]
1809        let (ordering1, _) = pipeline.compute_ordering();
1810        assert_eq!(ordering1, vec![0, 1]);
1811
1812        // Make b much more flaky → higher p/c → should come first
1813        for _ in 0..20 {
1814            pipeline.update(&ValidationOutcome {
1815                id: b,
1816                passed: false,
1817                duration: Duration::from_millis(10),
1818            });
1819        }
1820        for _ in 0..20 {
1821            pipeline.update(&ValidationOutcome {
1822                id: a,
1823                passed: true,
1824                duration: Duration::from_millis(10),
1825            });
1826        }
1827
1828        let (ordering2, _) = pipeline.compute_ordering();
1829        assert_eq!(ordering2[0], 1, "flaky validator b should now come first");
1830    }
1831
1832    #[test]
1833    fn summary_natural_cost_matches_sequential_order() {
1834        let mut pipeline = ValidationPipeline::new();
1835        pipeline.register("a", Duration::from_millis(10));
1836        pipeline.register("b", Duration::from_millis(20));
1837        pipeline.register("c", Duration::from_millis(5));
1838
1839        let summary = pipeline.summary();
1840        let natural: Vec<usize> = (0..3).collect();
1841        let natural_cost = pipeline.expected_cost(&natural);
1842        assert!(
1843            (summary.natural_cost_secs - natural_cost).abs() < 1e-15,
1844            "summary natural cost should match sequential ordering"
1845        );
1846    }
1847
1848    #[test]
1849    fn validator_count_after_multiple_registers() {
1850        let mut pipeline = ValidationPipeline::new();
1851        assert_eq!(pipeline.validator_count(), 0);
1852        pipeline.register("a", Duration::from_millis(5));
1853        assert_eq!(pipeline.validator_count(), 1);
1854        pipeline.register("b", Duration::from_millis(10));
1855        assert_eq!(pipeline.validator_count(), 2);
1856        pipeline.register("c", Duration::from_millis(15));
1857        assert_eq!(pipeline.validator_count(), 3);
1858    }
1859
1860    #[test]
1861    fn alpha_beta_after_mixed_outcomes() {
1862        let mut pipeline = ValidationPipeline::new();
1863        let id = pipeline.register("v", Duration::from_millis(5));
1864
1865        // Prior: α=1, β=1
1866        // 5 failures, 3 successes → α=6, β=4
1867        for _ in 0..5 {
1868            pipeline.update(&ValidationOutcome {
1869                id,
1870                passed: false,
1871                duration: Duration::from_millis(5),
1872            });
1873        }
1874        for _ in 0..3 {
1875            pipeline.update(&ValidationOutcome {
1876                id,
1877                passed: true,
1878                duration: Duration::from_millis(5),
1879            });
1880        }
1881
1882        let stats = pipeline.stats(id).unwrap();
1883        assert!((stats.alpha - 6.0).abs() < 1e-10);
1884        assert!((stats.beta - 4.0).abs() < 1e-10);
1885        assert!((stats.failure_prob() - 0.6).abs() < 1e-10);
1886    }
1887
1888    #[test]
1889    fn pipeline_result_total_cost_accumulates() {
1890        let mut pipeline = ValidationPipeline::new();
1891        pipeline.register("a", Duration::from_millis(5));
1892        pipeline.register("b", Duration::from_millis(10));
1893
1894        let result = pipeline.run(|id| {
1895            if id == 0 {
1896                (true, Duration::from_millis(7))
1897            } else {
1898                (true, Duration::from_millis(12))
1899            }
1900        });
1901
1902        assert_eq!(result.total_cost, Duration::from_millis(19));
1903    }
1904
1905    #[test]
1906    fn cost_ema_multiple_updates_converge() {
1907        let mut pipeline = ValidationPipeline::with_config(PipelineConfig {
1908            gamma: 0.5,
1909            ..Default::default()
1910        });
1911        let id = pipeline.register("v", Duration::from_millis(100));
1912
1913        // Repeatedly observe 10ms → EMA should converge toward 10ms
1914        for _ in 0..50 {
1915            pipeline.update(&ValidationOutcome {
1916                id,
1917                passed: true,
1918                duration: Duration::from_millis(10),
1919            });
1920        }
1921        let cost = pipeline.stats(id).unwrap().cost_ema;
1922        // After 50 iterations with gamma=0.5, should be very close to 10ms
1923        assert!(
1924            (cost.as_millis() as i64 - 10).abs() <= 1,
1925            "EMA should converge to observed value: got {}ms",
1926            cost.as_millis()
1927        );
1928    }
1929
1930    #[test]
1931    fn ledger_ranks_are_contiguous() {
1932        let mut pipeline = ValidationPipeline::new();
1933        for i in 0..5 {
1934            pipeline.register(format!("v{i}"), Duration::from_millis((i as u64 + 1) * 10));
1935        }
1936
1937        let (_, ledger) = pipeline.compute_ordering();
1938        let mut ranks: Vec<usize> = ledger.iter().map(|e| e.rank).collect();
1939        ranks.sort_unstable();
1940        assert_eq!(ranks, vec![0, 1, 2, 3, 4]);
1941    }
1942
1943    #[test]
1944    fn ledger_scores_descending() {
1945        let mut pipeline = ValidationPipeline::new();
1946        pipeline.register("a", Duration::from_millis(5));
1947        pipeline.register("b", Duration::from_millis(50));
1948        pipeline.register("c", Duration::from_millis(1));
1949
1950        let (_, ledger) = pipeline.compute_ordering();
1951        for window in ledger.windows(2) {
1952            assert!(
1953                window[0].score >= window[1].score,
1954                "ledger scores should be descending: {} < {}",
1955                window[0].score,
1956                window[1].score
1957            );
1958        }
1959    }
1960}