Skip to main content

wafrift_evolution/
types.rs

1//! Core types for the evolution engine.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::time::{Duration, Instant};
6
7/// Rich oracle verdict providing gradient signals for fitness.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct OracleVerdict {
10    /// Whether the payload passed the WAF.
11    pub passed: bool,
12    /// Delta from baseline response status code.
13    pub status_delta: i16,
14    /// Delta from baseline response body size.
15    pub body_delta: i32,
16    /// Response latency in milliseconds.
17    pub latency_ms: u32,
18    /// Oracle confidence (0.0–1.0).
19    pub confidence: f64,
20    /// Number of WAF rules triggered.
21    pub triggered_rules: u32,
22    /// WAF rule ID that fired (e.g. "942100" for ModSecurity CRS SQL injection).
23    /// `None` if the request passed or the WAF did not expose the rule ID.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub rule_id: Option<String>,
26}
27
28/// Penalty per triggered WAF rule in fitness calculation.
29const RULE_PENALTY_PER_RULE: f64 = 0.05;
30/// Maximum rule-based penalty (caps at 6 rules).
31const MAX_RULE_PENALTY: f64 = 0.3;
32/// Reference latency in ms for normalising the latency penalty.
33const LATENCY_REFERENCE_MS: f64 = 5000.0;
34/// Maximum latency-based penalty.
35const MAX_LATENCY_PENALTY: f64 = 0.1;
36/// Reference body-size delta in bytes for normalising the body penalty.
37const BODY_DELTA_REFERENCE: f64 = 10000.0;
38/// Maximum body-delta-based penalty.
39const MAX_BODY_PENALTY: f64 = 0.1;
40/// Maximum partial-credit pool for a non-passing verdict.
41const MAX_PARTIAL_CREDIT: f64 = 0.3;
42/// Confidence bonus multiplier.
43const CONFIDENCE_BONUS_MULTIPLIER: f64 = 0.05;
44
45impl OracleVerdict {
46    /// Create a binary pass/fail verdict.
47    #[must_use]
48    pub fn from_bool(passed: bool) -> Self {
49        Self {
50            passed,
51            status_delta: 0,
52            body_delta: 0,
53            latency_ms: 0,
54            confidence: 1.0,
55            triggered_rules: if passed { 0 } else { 1 },
56            rule_id: None,
57        }
58    }
59
60    /// Compute a scalar fitness from the rich verdict.
61    ///
62    /// Rewards partial progress: fewer triggered rules, lower latency,
63    /// smaller body delta, and high oracle confidence.
64    #[must_use]
65    pub fn to_fitness(&self) -> f64 {
66        let base = if self.passed { 1.0 } else { 0.0 };
67        let partial = if self.passed {
68            0.0
69        } else {
70            // Partial credit for fewer triggered rules, faster response
71            let rule_penalty =
72                (self.triggered_rules as f64 * RULE_PENALTY_PER_RULE).min(MAX_RULE_PENALTY);
73            let latency_penalty =
74                (self.latency_ms as f64 / LATENCY_REFERENCE_MS).min(MAX_LATENCY_PENALTY);
75            let body_penalty =
76                (self.body_delta.abs() as f64 / BODY_DELTA_REFERENCE).min(MAX_BODY_PENALTY);
77            MAX_PARTIAL_CREDIT - rule_penalty - latency_penalty - body_penalty
78        };
79        let confidence_bonus = self.confidence * CONFIDENCE_BONUS_MULTIPLIER;
80        (base + partial + confidence_bonus).clamp(0.0, 1.0)
81    }
82}
83
84impl Default for OracleVerdict {
85    fn default() -> Self {
86        Self::from_bool(false)
87    }
88}
89
90/// Feedback from evaluating a candidate.
91#[derive(Debug, Clone, PartialEq)]
92pub enum Feedback {
93    /// Payload passed the WAF.
94    Passed,
95    /// Payload was blocked.
96    Blocked,
97    /// Target returned an error (5xx, timeout, etc.).
98    TargetError(String),
99}
100
101impl Feedback {
102    /// Convert feedback to an oracle verdict with default metadata.
103    #[must_use]
104    pub fn to_verdict(&self) -> OracleVerdict {
105        match self {
106            Self::Passed => OracleVerdict::from_bool(true),
107            Self::Blocked => OracleVerdict::from_bool(false),
108            Self::TargetError(_) => OracleVerdict {
109                passed: false,
110                status_delta: 500,
111                body_delta: 0,
112                latency_ms: 0,
113                confidence: 0.0,
114                triggered_rules: 0,
115                rule_id: None,
116            },
117        }
118    }
119}
120
121/// Hard budget limits for the search.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123pub struct Budget {
124    /// Maximum total oracle evaluations (requests).
125    pub max_requests: usize,
126    /// Maximum generations.
127    pub max_generations: u32,
128    /// Maximum time in seconds.
129    pub max_time_seconds: u64,
130    /// Early-termination stagnation threshold (generations with no improvement).
131    pub stagnation_limit: u32,
132}
133
134impl Budget {
135    /// Default conservative budget.
136    #[must_use]
137    pub fn default_wafrift() -> Self {
138        Self {
139            max_requests: 10_000,
140            max_generations: 200,
141            max_time_seconds: 3_600,
142            stagnation_limit: 10,
143        }
144    }
145}
146
147impl Default for Budget {
148    fn default() -> Self {
149        Self::default_wafrift()
150    }
151}
152
153/// Errors that can occur in the evolution engine.
154#[derive(Debug, thiserror::Error)]
155pub enum EvolutionError {
156    #[error("invalid chromosome index: {0}")]
157    InvalidChromosomeIndex(usize),
158    #[error("budget exhausted: {0}")]
159    BudgetExhausted(String),
160    #[error("target health critical: {0}")]
161    TargetHealthCritical(String),
162    #[error("serialization failed: {0}")]
163    SerializationFailed(#[source] serde_json::Error),
164    #[error("deserialization failed: {0}")]
165    DeserializationFailed(#[source] serde_json::Error),
166    #[error("io error: {0}")]
167    Io(#[from] std::io::Error),
168    #[error("search algorithm error: {0}")]
169    AlgorithmError(String),
170    #[error("data exceeds size limit: {context} ({size} bytes, max {max})")]
171    OversizedData {
172        context: String,
173        size: usize,
174        max: usize,
175    },
176}
177
178/// Reason for terminating evolution.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub enum TerminationReason {
181    BudgetExhausted,
182    MaxGenerationsReached,
183    TimeLimitReached,
184    StagnationLimitReached,
185    TargetHealthCritical,
186    BypassFound,
187}
188
189/// Action emitted by the intelligence loop state machine.
190#[derive(Debug, Clone, PartialEq)]
191pub enum LoopAction {
192    /// Evaluate a differential probe.
193    SendProbe(crate::differential::Probe),
194    /// Evaluate an evolved payload.
195    SendPayload(crate::evolution::Chromosome),
196    /// Save checkpoint to disk.
197    SaveCheckpoint,
198    /// Terminate the loop.
199    Terminate(TerminationReason),
200}
201
202/// Target health monitor with exponential backoff.
203#[derive(Debug, Clone)]
204pub struct TargetHealthMonitor {
205    consecutive_errors: u32,
206    last_error: Option<Instant>,
207    backoff_seconds: u64,
208    max_backoff_seconds: u64,
209    error_threshold: u32,
210}
211
212impl TargetHealthMonitor {
213    #[must_use]
214    pub fn new() -> Self {
215        Self {
216            consecutive_errors: 0,
217            last_error: None,
218            backoff_seconds: 1,
219            max_backoff_seconds: 300,
220            error_threshold: 5,
221        }
222    }
223
224    /// Record a target error.
225    pub fn record_error(&mut self) {
226        self.consecutive_errors += 1;
227        self.last_error = Some(Instant::now());
228        self.backoff_seconds = (self.backoff_seconds * 2).min(self.max_backoff_seconds);
229    }
230
231    /// Record a successful request.
232    pub fn record_success(&mut self) {
233        self.consecutive_errors = 0;
234        self.backoff_seconds = 1;
235    }
236
237    /// Check if the target is considered healthy.
238    #[must_use]
239    pub fn is_healthy(&self) -> bool {
240        self.consecutive_errors < self.error_threshold
241    }
242
243    /// Current backoff duration.
244    #[must_use]
245    pub fn backoff(&self) -> Duration {
246        Duration::from_secs(self.backoff_seconds)
247    }
248
249    /// Whether we are currently in an active backoff period.
250    #[must_use]
251    pub fn in_backoff(&self) -> bool {
252        self.last_error
253            .is_some_and(|t| t.elapsed() < self.backoff())
254    }
255}
256
257impl Default for TargetHealthMonitor {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263/// Search statistics passed to algorithms for termination decisions.
264#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
265pub struct SearchStats {
266    pub generation: u32,
267    pub evaluations: usize,
268    pub best_fitness: f64,
269    pub stagnation_counter: u32,
270    #[serde(skip, default = "Instant::now")]
271    pub start_time: Instant,
272    pub start_time_system: std::time::SystemTime,
273}
274
275impl SearchStats {
276    pub fn new() -> Self {
277        Self {
278            generation: 0,
279            evaluations: 0,
280            best_fitness: 0.0,
281            stagnation_counter: 0,
282            start_time: Instant::now(),
283            start_time_system: std::time::SystemTime::now(),
284        }
285    }
286
287    pub fn fixup_start_time(&mut self) {
288        if let Ok(elapsed) = self.start_time_system.elapsed() {
289            self.start_time = Instant::now()
290                .checked_sub(elapsed)
291                .unwrap_or_else(Instant::now);
292        }
293    }
294}
295
296impl Default for SearchStats {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302/// Deduplication helpers.
303#[derive(Debug, Clone)]
304pub struct Deduper {
305    seen: HashSet<u64>,
306}
307
308impl Deduper {
309    #[must_use]
310    pub fn new() -> Self {
311        Self {
312            seen: HashSet::new(),
313        }
314    }
315
316    /// Compute a hash for a chromosome based on its genes.
317    #[must_use]
318    pub fn hash_chromosome(chromosome: &crate::evolution::Chromosome) -> u64 {
319        use std::collections::hash_map::DefaultHasher;
320        use std::hash::{Hash, Hasher};
321        let mut hasher = DefaultHasher::new();
322        for (name, value) in &chromosome.genes {
323            name.hash(&mut hasher);
324            value.hash(&mut hasher);
325        }
326        hasher.finish()
327    }
328
329    /// Check if this chromosome has been seen before.
330    #[must_use]
331    pub fn is_duplicate(&self, chromosome: &crate::evolution::Chromosome) -> bool {
332        self.seen.contains(&Self::hash_chromosome(chromosome))
333    }
334
335    /// Mark a chromosome as seen.
336    pub fn insert(&mut self, chromosome: &crate::evolution::Chromosome) {
337        self.seen.insert(Self::hash_chromosome(chromosome));
338    }
339
340    /// Insert multiple chromosomes.
341    pub fn insert_many(&mut self, chromosomes: &[crate::evolution::Chromosome]) {
342        for c in chromosomes {
343            self.insert(c);
344        }
345    }
346}
347
348impl Default for Deduper {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354/// Maximum checkpoint file size (bytes). Prevents OOM from
355/// maliciously large checkpoint files.
356pub(crate) const MAX_CHECKPOINT_BYTES: usize = 512 * 1024 * 1024;
357
358/// Pure size-gate used by both save and load. Extracted as a free
359/// function so the boundary contract is testable without allocating
360/// a 512 MiB-equivalent fixture. R55 pass-19 I7 (CLAUDE.md §12).
361fn reject_oversize_checkpoint(size: usize, path: &std::path::Path) -> Result<(), EvolutionError> {
362    if size > MAX_CHECKPOINT_BYTES {
363        Err(EvolutionError::OversizedData {
364            context: format!("checkpoint {}", path.display()),
365            size,
366            max: MAX_CHECKPOINT_BYTES,
367        })
368    } else {
369        Ok(())
370    }
371}
372
373/// Checkpoint persistence helpers.
374pub fn save_checkpoint(
375    path: &std::path::Path,
376    data: &impl Serialize,
377) -> Result<(), EvolutionError> {
378    let json = serde_json::to_string_pretty(data).map_err(EvolutionError::SerializationFailed)?;
379    reject_oversize_checkpoint(json.len(), path)?;
380    std::fs::write(path, json)?;
381    Ok(())
382}
383
384/// Load a checkpoint from disk.
385pub fn load_checkpoint<T: for<'de> Deserialize<'de>>(
386    path: &std::path::Path,
387) -> Result<T, EvolutionError> {
388    let meta = std::fs::metadata(path)?;
389    // R55 pass-19 I5 (CLAUDE.md §15 AUDIT): `meta.len()` is `u64`.
390    // The pre-fix `as usize` silently truncated on 32-bit targets so
391    // a 5 GiB file with `len = 0x_0000_0001_4000_0000` came through
392    // as 0x4000_0000 (1 GiB) — under the 512 MiB cap, advisory gate
393    // skipped, defense-in-depth ride on the bounded reader. Saturate
394    // to `usize::MAX` on overflow so the gate always fires.
395    let len = usize::try_from(meta.len()).unwrap_or(usize::MAX);
396    reject_oversize_checkpoint(len, path)?;
397    // The metadata gate above is advisory; the bounded reader is
398    // authoritative (defends against symlinks reporting len=0 and
399    // TOCTOU file-replacement between stat and read).
400    let json = crate::safe_io::read_capped_text(path, MAX_CHECKPOINT_BYTES)?;
401    serde_json::from_str(&json).map_err(EvolutionError::DeserializationFailed)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::time::Duration;
408
409    #[test]
410    fn oracle_verdict_from_bool_true() {
411        let v = OracleVerdict::from_bool(true);
412        assert!(v.passed);
413        assert_eq!(v.triggered_rules, 0);
414        assert_eq!(v.confidence, 1.0);
415    }
416
417    /// R55 pass-19 I7 (CLAUDE.md §12 TESTING boundary): the save +
418    /// load size gate is `size > MAX_CHECKPOINT_BYTES` (strict
419    /// greater-than). Pin both boundary points without allocating a
420    /// 512 MiB-equivalent fixture — the pure gate is extracted so the
421    /// math is testable on an `i+1` integer.
422    #[test]
423    fn reject_oversize_checkpoint_accepts_exact_max() {
424        let p = std::path::PathBuf::from("/tmp/x");
425        let out = reject_oversize_checkpoint(MAX_CHECKPOINT_BYTES, &p);
426        assert!(out.is_ok(), "exactly MAX must be accepted, got {out:?}");
427    }
428
429    #[test]
430    fn reject_oversize_checkpoint_rejects_one_past_max() {
431        let p = std::path::PathBuf::from("/tmp/x");
432        let out = reject_oversize_checkpoint(MAX_CHECKPOINT_BYTES + 1, &p);
433        let Err(EvolutionError::OversizedData { size, max, .. }) = out else {
434            panic!("expected OversizedData, got {out:?}");
435        };
436        assert_eq!(size, MAX_CHECKPOINT_BYTES + 1);
437        assert_eq!(max, MAX_CHECKPOINT_BYTES);
438    }
439
440    #[test]
441    fn reject_oversize_checkpoint_zero_is_accepted() {
442        // An empty checkpoint is malformed for load but `size = 0`
443        // alone is not an oversize signal — the parser surfaces the
444        // emptiness as a deser error, not this gate.
445        let p = std::path::PathBuf::from("/tmp/x");
446        assert!(reject_oversize_checkpoint(0, &p).is_ok());
447    }
448
449    #[test]
450    fn oracle_verdict_from_bool_false() {
451        let v = OracleVerdict::from_bool(false);
452        assert!(!v.passed);
453        assert_eq!(v.triggered_rules, 1);
454    }
455
456    #[test]
457    fn oracle_verdict_fitness_passed_is_one() {
458        let v = OracleVerdict::from_bool(true);
459        // clamped to 1.0 (1.0 base + 0.05 confidence bonus)
460        assert_eq!(v.to_fitness(), 1.0);
461    }
462
463    #[test]
464    fn oracle_verdict_fitness_blocked_penalizes_rules() {
465        let v = OracleVerdict {
466            passed: false,
467            triggered_rules: 5,
468            confidence: 1.0,
469            ..Default::default()
470        };
471        // 0.3 - 0.25 - 0 - 0 + 0.05 = 0.10
472        assert!((v.to_fitness() - 0.10).abs() < 0.01);
473    }
474
475    #[test]
476    fn feedback_to_verdict_passed() {
477        assert!(Feedback::Passed.to_verdict().passed);
478    }
479
480    #[test]
481    fn feedback_to_verdict_target_error() {
482        let v = Feedback::TargetError("timeout".into()).to_verdict();
483        assert!(!v.passed);
484        assert_eq!(v.status_delta, 500);
485        assert_eq!(v.confidence, 0.0);
486    }
487
488    #[test]
489    fn budget_default_wafrift_values() {
490        let b = Budget::default_wafrift();
491        assert_eq!(b.max_requests, 10_000);
492        assert_eq!(b.max_generations, 200);
493        assert_eq!(b.max_time_seconds, 3_600);
494        assert_eq!(b.stagnation_limit, 10);
495    }
496
497    #[test]
498    fn target_health_monitor_starts_healthy() {
499        let h = TargetHealthMonitor::new();
500        assert!(h.is_healthy());
501        assert!(!h.in_backoff());
502        assert_eq!(h.backoff(), Duration::from_secs(1));
503    }
504
505    #[test]
506    fn target_health_monitor_records_errors() {
507        let mut h = TargetHealthMonitor::new();
508        for _ in 0..4 {
509            h.record_error();
510        }
511        assert!(h.is_healthy());
512        assert_eq!(h.backoff(), Duration::from_secs(16));
513        h.record_error();
514        assert!(!h.is_healthy());
515    }
516
517    #[test]
518    fn target_health_monitor_resets_on_success() {
519        let mut h = TargetHealthMonitor::new();
520        h.record_error();
521        h.record_error();
522        h.record_success();
523        assert!(h.is_healthy());
524        assert_eq!(h.backoff(), Duration::from_secs(1));
525    }
526
527    #[test]
528    fn deduper_detects_duplicates() {
529        use crate::evolution::Chromosome;
530        let c1 = Chromosome::new(vec![("a".into(), "1".into())]);
531        let c2 = Chromosome::new(vec![("a".into(), "1".into())]);
532        let c3 = Chromosome::new(vec![("a".into(), "2".into())]);
533
534        let mut d = Deduper::new();
535        assert!(!d.is_duplicate(&c1));
536        d.insert(&c1);
537        assert!(d.is_duplicate(&c2));
538        assert!(!d.is_duplicate(&c3));
539    }
540
541    #[test]
542    fn deduper_insert_many() {
543        use crate::evolution::Chromosome;
544        let c1 = Chromosome::new(vec![("a".into(), "1".into())]);
545        let c2 = Chromosome::new(vec![("b".into(), "2".into())]);
546        let mut d = Deduper::new();
547        d.insert_many(&[c1.clone(), c2.clone()]);
548        assert!(d.is_duplicate(&c1));
549        assert!(d.is_duplicate(&c2));
550    }
551
552    #[test]
553    fn deduper_hash_consistent() {
554        use crate::evolution::Chromosome;
555        let c = Chromosome::new(vec![("x".into(), "y".into())]);
556        let h1 = Deduper::hash_chromosome(&c);
557        let h2 = Deduper::hash_chromosome(&c);
558        assert_eq!(h1, h2);
559    }
560
561    // ── OracleVerdict::to_fitness edge cases ─────────────────────────────
562
563    #[test]
564    fn oracle_verdict_fitness_extreme_latency_clamped() {
565        // latency_ms = u32::MAX → latency penalty must clamp to MAX_LATENCY_PENALTY (0.1)
566        let v = OracleVerdict {
567            passed: false,
568            status_delta: 0,
569            body_delta: 0,
570            latency_ms: u32::MAX,
571            confidence: 0.0,
572            triggered_rules: 0,
573            rule_id: None,
574        };
575        let fitness = v.to_fitness();
576        // MAX_PARTIAL_CREDIT (0.3) - MAX_LATENCY_PENALTY (0.1) - 0 - 0 + 0.0 = 0.2
577        assert!(
578            (0.0..=1.0).contains(&fitness),
579            "fitness must be clamped to [0,1], got {fitness}"
580        );
581        // Latency penalty capped at 0.1; so partial credit = 0.3 - 0 - 0.1 - 0 = 0.2.
582        assert!(
583            (fitness - 0.2).abs() < 0.01,
584            "extreme latency must cap at MAX_LATENCY_PENALTY=0.1; expected ~0.2, got {fitness}"
585        );
586    }
587
588    #[test]
589    fn oracle_verdict_fitness_extreme_body_delta_clamped() {
590        // body_delta = i32::MAX → body penalty must clamp to MAX_BODY_PENALTY (0.1).
591        let v = OracleVerdict {
592            passed: false,
593            status_delta: 0,
594            body_delta: i32::MAX,
595            latency_ms: 0,
596            confidence: 0.0,
597            triggered_rules: 0,
598            rule_id: None,
599        };
600        let fitness = v.to_fitness();
601        // 0.3 - 0 - 0 - 0.1 = 0.2
602        assert!(
603            (fitness - 0.2).abs() < 0.01,
604            "extreme body_delta must cap at MAX_BODY_PENALTY=0.1; expected ~0.2, got {fitness}"
605        );
606    }
607
608    #[test]
609    fn oracle_verdict_fitness_negative_body_delta_uses_abs() {
610        // body_delta is i32; negative values must use abs() in the penalty.
611        let pos = OracleVerdict {
612            passed: false,
613            body_delta: 10_000,
614            confidence: 0.0,
615            ..OracleVerdict::from_bool(false)
616        };
617        let neg = OracleVerdict {
618            passed: false,
619            body_delta: -10_000,
620            confidence: 0.0,
621            ..OracleVerdict::from_bool(false)
622        };
623        let f_pos = pos.to_fitness();
624        let f_neg = neg.to_fitness();
625        assert!(
626            (f_pos - f_neg).abs() < 0.01,
627            "positive and negative body_delta of same magnitude must produce equal fitness: pos={f_pos} neg={f_neg}"
628        );
629    }
630
631    #[test]
632    fn oracle_verdict_fitness_max_rules_caps_at_max_rule_penalty() {
633        // Triggering many rules must not penalise more than MAX_RULE_PENALTY (0.3),
634        // which would push partial credit below 0.
635        let v = OracleVerdict {
636            passed: false,
637            triggered_rules: 1000,
638            confidence: 0.0,
639            latency_ms: 0,
640            body_delta: 0,
641            ..OracleVerdict::from_bool(false)
642        };
643        let fitness = v.to_fitness();
644        // 0.3 - 0.3 - 0 - 0 + 0 = 0.0, clamped to 0.
645        assert!(fitness >= 0.0, "fitness must not go below 0: {fitness}");
646    }
647
648    #[test]
649    fn oracle_verdict_fitness_passed_ignores_penalties() {
650        // A passing verdict must return exactly 1.0 regardless of rule counts / latency.
651        let v = OracleVerdict {
652            passed: true,
653            triggered_rules: 999,
654            latency_ms: u32::MAX,
655            body_delta: i32::MAX,
656            confidence: 1.0,
657            status_delta: 0,
658            rule_id: None,
659        };
660        assert_eq!(v.to_fitness(), 1.0, "passed verdict must clamp to 1.0");
661    }
662
663    // ── Feedback::TargetError string preserved ────────────────────────────
664
665    #[test]
666    fn feedback_target_error_string_is_preserved_in_to_verdict() {
667        // The error message is held in the Feedback variant, not in OracleVerdict,
668        // but the resulting verdict must always carry status_delta=500 and confidence=0.
669        let msg = "connection reset by peer";
670        let f = Feedback::TargetError(msg.to_string());
671        // Verify the string is inside the enum.
672        assert!(matches!(&f, Feedback::TargetError(s) if s == msg));
673        let verdict = f.to_verdict();
674        assert!(!verdict.passed);
675        assert_eq!(verdict.status_delta, 500);
676        assert_eq!(verdict.confidence, 0.0);
677        assert_eq!(verdict.triggered_rules, 0);
678    }
679
680    // ── TargetHealthMonitor backoff caps ──────────────────────────────────
681
682    #[test]
683    fn target_health_monitor_backoff_caps_at_max_backoff_seconds() {
684        let mut h = TargetHealthMonitor::new();
685        // Doubling from 1 → 2 → 4 → 8 → … until we hit max (300 seconds).
686        // After enough errors the backoff must not exceed max.
687        for _ in 0..20 {
688            h.record_error();
689        }
690        assert!(
691            h.backoff() <= Duration::from_secs(300),
692            "backoff must cap at max_backoff_seconds=300, got {:?}",
693            h.backoff()
694        );
695        assert_eq!(
696            h.backoff(),
697            Duration::from_secs(300),
698            "after many errors backoff must sit at exactly max_backoff_seconds"
699        );
700    }
701
702    #[test]
703    fn target_health_monitor_in_backoff_true_immediately_after_error() {
704        let mut h = TargetHealthMonitor::new();
705        h.record_error();
706        // backoff is 2s (1*2). in_backoff() checks elapsed < backoff().
707        // Immediately after recording the error, elapsed ~= 0 << 2s.
708        assert!(
709            h.in_backoff(),
710            "in_backoff must be true immediately after recording an error"
711        );
712    }
713
714    #[test]
715    fn target_health_monitor_in_backoff_false_after_success() {
716        let mut h = TargetHealthMonitor::new();
717        h.record_error();
718        h.record_success();
719        // After success, backoff resets to 1s and last_error is not cleared,
720        // but last_error elapsed should exceed the now-tiny backoff.
721        // Actually record_success() doesn't reset last_error, so in_backoff
722        // depends on whether elapsed < 1s. We reset the backoff to 1s.
723        // The test verifies that is_healthy() is true (the primary health check).
724        assert!(h.is_healthy(), "after success must be healthy");
725        // backoff resets to 1s after success.
726        assert_eq!(h.backoff(), Duration::from_secs(1));
727    }
728
729    // ── Deduper with empty chromosome genes ──────────────────────────────
730
731    #[test]
732    fn deduper_empty_genes_chromosome_is_handled() {
733        use crate::evolution::Chromosome;
734        let empty = Chromosome::new(vec![]);
735        let mut d = Deduper::new();
736        // An empty-gene chromosome must hash consistently and be deduplicable.
737        assert!(!d.is_duplicate(&empty));
738        d.insert(&empty);
739        let empty2 = Chromosome::new(vec![]);
740        assert!(
741            d.is_duplicate(&empty2),
742            "two empty-gene chromosomes must be duplicates"
743        );
744    }
745
746    // ── SearchStats::fixup_start_time no-panic ────────────────────────────
747
748    #[test]
749    fn search_stats_fixup_start_time_does_not_panic() {
750        // fixup_start_time uses start_time_system.elapsed() and Instant::now().
751        // It must not panic regardless of system clock skew.
752        let mut stats = SearchStats::new();
753        // Set start_time_system to now — elapsed will be ~0 (healthy path).
754        stats.fixup_start_time();
755        // Set to an ancient SystemTime that may trigger checked_sub failure.
756        stats.start_time_system = std::time::SystemTime::UNIX_EPOCH;
757        stats.fixup_start_time(); // must not panic; falls back to Instant::now()
758    }
759
760    #[test]
761    fn search_stats_default_values() {
762        let s = SearchStats::new();
763        assert_eq!(s.generation, 0);
764        assert_eq!(s.evaluations, 0);
765        assert_eq!(s.best_fitness, 0.0);
766        assert_eq!(s.stagnation_counter, 0);
767    }
768
769    // ── Budget::default() anti-rig ────────────────────────────────────────
770
771    #[test]
772    fn budget_default_matches_default_wafrift() {
773        let via_default = Budget::default();
774        let via_fn = Budget::default_wafrift();
775        assert_eq!(
776            via_default, via_fn,
777            "Budget::default() must match default_wafrift()"
778        );
779    }
780
781    // ── OracleVerdict serde round-trip ────────────────────────────────────
782
783    #[test]
784    fn oracle_verdict_serde_roundtrip() {
785        let v = OracleVerdict {
786            passed: true,
787            status_delta: 200,
788            body_delta: -500,
789            latency_ms: 123,
790            confidence: 0.9,
791            triggered_rules: 0,
792            rule_id: Some("942100".into()),
793        };
794        let json = serde_json::to_string(&v).expect("serialize");
795        let back: OracleVerdict = serde_json::from_str(&json).expect("deserialize");
796        assert_eq!(v, back);
797    }
798
799    #[test]
800    fn oracle_verdict_rule_id_none_omitted_in_json() {
801        // rule_id = None must be omitted from the serialised JSON
802        // (skip_serializing_if = "Option::is_none").
803        let v = OracleVerdict::from_bool(false);
804        let json = serde_json::to_string(&v).unwrap();
805        assert!(
806            !json.contains("rule_id"),
807            "rule_id=None must not appear in serialised JSON: {json}"
808        );
809    }
810}