1use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::time::{Duration, Instant};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct OracleVerdict {
10 pub passed: bool,
12 pub status_delta: i16,
14 pub body_delta: i32,
16 pub latency_ms: u32,
18 pub confidence: f64,
20 pub triggered_rules: u32,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub rule_id: Option<String>,
26}
27
28const RULE_PENALTY_PER_RULE: f64 = 0.05;
30const MAX_RULE_PENALTY: f64 = 0.3;
32const LATENCY_REFERENCE_MS: f64 = 5000.0;
34const MAX_LATENCY_PENALTY: f64 = 0.1;
36const BODY_DELTA_REFERENCE: f64 = 10000.0;
38const MAX_BODY_PENALTY: f64 = 0.1;
40const MAX_PARTIAL_CREDIT: f64 = 0.3;
42const CONFIDENCE_BONUS_MULTIPLIER: f64 = 0.05;
44
45impl OracleVerdict {
46 #[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 #[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 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#[derive(Debug, Clone, PartialEq)]
92pub enum Feedback {
93 Passed,
95 Blocked,
97 TargetError(String),
99}
100
101impl Feedback {
102 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123pub struct Budget {
124 pub max_requests: usize,
126 pub max_generations: u32,
128 pub max_time_seconds: u64,
130 pub stagnation_limit: u32,
132}
133
134impl Budget {
135 #[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#[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#[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#[derive(Debug, Clone, PartialEq)]
191pub enum LoopAction {
192 SendProbe(crate::differential::Probe),
194 SendPayload(crate::evolution::Chromosome),
196 SaveCheckpoint,
198 Terminate(TerminationReason),
200}
201
202#[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 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 pub fn record_success(&mut self) {
233 self.consecutive_errors = 0;
234 self.backoff_seconds = 1;
235 }
236
237 #[must_use]
239 pub fn is_healthy(&self) -> bool {
240 self.consecutive_errors < self.error_threshold
241 }
242
243 #[must_use]
245 pub fn backoff(&self) -> Duration {
246 Duration::from_secs(self.backoff_seconds)
247 }
248
249 #[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#[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#[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 #[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 #[must_use]
331 pub fn is_duplicate(&self, chromosome: &crate::evolution::Chromosome) -> bool {
332 self.seen.contains(&Self::hash_chromosome(chromosome))
333 }
334
335 pub fn insert(&mut self, chromosome: &crate::evolution::Chromosome) {
337 self.seen.insert(Self::hash_chromosome(chromosome));
338 }
339
340 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
354pub(crate) const MAX_CHECKPOINT_BYTES: usize = 512 * 1024 * 1024;
357
358fn 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
373pub 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
384pub 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 let len = usize::try_from(meta.len()).unwrap_or(usize::MAX);
396 reject_oversize_checkpoint(len, path)?;
397 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 #[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 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 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 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 #[test]
564 fn oracle_verdict_fitness_extreme_latency_clamped() {
565 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 assert!(
578 (0.0..=1.0).contains(&fitness),
579 "fitness must be clamped to [0,1], got {fitness}"
580 );
581 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 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 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 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 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 assert!(fitness >= 0.0, "fitness must not go below 0: {fitness}");
646 }
647
648 #[test]
649 fn oracle_verdict_fitness_passed_ignores_penalties() {
650 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 #[test]
666 fn feedback_target_error_string_is_preserved_in_to_verdict() {
667 let msg = "connection reset by peer";
670 let f = Feedback::TargetError(msg.to_string());
671 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 #[test]
683 fn target_health_monitor_backoff_caps_at_max_backoff_seconds() {
684 let mut h = TargetHealthMonitor::new();
685 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 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 assert!(h.is_healthy(), "after success must be healthy");
725 assert_eq!(h.backoff(), Duration::from_secs(1));
727 }
728
729 #[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 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 #[test]
749 fn search_stats_fixup_start_time_does_not_panic() {
750 let mut stats = SearchStats::new();
753 stats.fixup_start_time();
755 stats.start_time_system = std::time::SystemTime::UNIX_EPOCH;
757 stats.fixup_start_time(); }
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 #[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 #[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 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}