1use crate::differential::{DifferentialResult, Probe, generate_probes, generate_quick_probes};
4use crate::evolution::{Chromosome, EvolutionEngine};
5use crate::types::{Budget, Feedback, LoopAction, OracleVerdict, TerminationReason};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9enum Phase {
10 DifferentialProbing,
11 Evolution,
12 Done,
13}
14
15#[derive(Debug, Clone)]
17pub struct IntelligenceLoop {
18 differential: DifferentialResult,
19 evolution: EvolutionEngine,
20 probes_completed: usize,
21 feedback_count: usize,
22 phase: Phase,
23 min_probes: usize,
24 probe_queue: Vec<Probe>,
25 eval_queue: Vec<(usize, Chromosome)>,
26 budget: Budget,
27}
28
29impl IntelligenceLoop {
30 #[must_use]
32 pub fn new(population_size: usize) -> Self {
33 Self::with_budget(population_size, 10, Budget::default())
34 }
35
36 #[must_use]
38 pub fn with_budget(population_size: usize, min_probes: usize, budget: Budget) -> Self {
39 let mut evolution = EvolutionEngine::new(population_size);
40 evolution.budget = budget;
41 Self {
42 differential: DifferentialResult::new(),
43 evolution,
44 probes_completed: 0,
45 feedback_count: 0,
46 phase: Phase::DifferentialProbing,
47 min_probes,
48 probe_queue: generate_probes(),
49 eval_queue: Vec::new(),
50 budget,
51 }
52 }
53
54 #[must_use]
56 pub fn generate_probes(&self) -> Vec<Probe> {
57 if self.probe_queue.len()
58 > self
59 .budget
60 .max_requests
61 .saturating_sub(self.probes_completed)
62 {
63 generate_quick_probes()
64 } else {
65 generate_probes()
66 }
67 }
68
69 #[must_use]
71 pub fn generate_quick_probes(&self) -> Vec<Probe> {
72 generate_quick_probes()
73 }
74
75 pub fn record_probe(&mut self, probe: &Probe, was_blocked: bool) {
77 self.differential.record(probe, was_blocked);
78 self.probes_completed += 1;
79 }
80
81 #[must_use]
83 pub fn differential_results(&self) -> &DifferentialResult {
84 &self.differential
85 }
86
87 #[must_use]
89 pub fn suggested_evasions(&self) -> Vec<String> {
90 self.differential.suggest_evasions()
91 }
92
93 #[must_use]
95 pub fn waf_report(&self) -> String {
96 self.differential.report()
97 }
98
99 #[must_use]
101 pub fn next_candidate(&mut self) -> Option<(usize, &Chromosome)> {
102 self.evolution.next_candidate()
103 }
104
105 pub fn batch_candidates(&mut self, n: usize) -> Vec<(usize, Chromosome)> {
107 self.evolution.batch_candidates(n)
108 }
109
110 pub fn record_feedback(&mut self, chromosome_index: usize, passed: bool) {
114 if let Err(e) = self.evolution.record_feedback(chromosome_index, passed) {
115 tracing::warn!(
116 ?e,
117 chromosome_index,
118 "evolution.record_feedback rejected — likely stale chromosome index"
119 );
120 }
121 self.feedback_count += 1;
122 }
123
124 pub fn record_verdict(&mut self, chromosome_index: usize, verdict: &OracleVerdict) {
127 if let Err(e) = self.evolution.record_verdict(chromosome_index, verdict) {
128 tracing::warn!(
129 ?e,
130 chromosome_index,
131 "evolution.record_verdict rejected — likely stale chromosome index"
132 );
133 }
134 self.feedback_count += 1;
135 }
136
137 pub fn evolve(&mut self) {
139 self.evolution.evolve();
140 }
141
142 #[must_use]
144 pub fn best_combination(&self) -> Option<&Chromosome> {
145 self.evolution.best()
146 }
147
148 #[must_use]
150 pub fn probes_completed(&self) -> usize {
151 self.probes_completed
152 }
153
154 #[must_use]
156 pub fn feedback_count(&self) -> usize {
157 self.feedback_count
158 }
159
160 #[must_use]
162 pub fn diversity(&self) -> f64 {
163 self.evolution.diversity_score()
164 }
165
166 #[must_use]
168 pub fn has_sufficient_data(&self) -> bool {
169 self.probes_completed >= self.min_probes
170 }
171
172 pub fn step(&mut self, feedback: Feedback) -> LoopAction {
178 if self.evolution.should_terminate() {
179 return LoopAction::Terminate(TerminationReason::BudgetExhausted);
180 }
181
182 if let Feedback::TargetError(ref msg) = feedback
187 && self.evolution.record_target_error(msg.clone()).is_err()
188 {
189 return LoopAction::Terminate(TerminationReason::TargetHealthCritical);
190 }
191
192 match self.phase {
193 Phase::DifferentialProbing => {
194 if let Feedback::Blocked | Feedback::Passed = feedback {
195 }
197 if self.probe_queue.is_empty() || self.probes_completed >= self.min_probes {
198 self.phase = Phase::Evolution;
199 return self.step(Feedback::Passed); }
201 let probe = self.probe_queue.remove(0);
202 LoopAction::SendProbe(probe)
203 }
204 Phase::Evolution => {
205 if self.eval_queue.is_empty() {
206 let remaining = self
207 .budget
208 .max_requests
209 .saturating_sub(self.evolution.request_count);
210 let batch_size = 4_usize.min(remaining).max(1);
211 self.eval_queue = self.evolution.batch_candidates(batch_size);
212 if self.eval_queue.is_empty() {
213 self.phase = Phase::Done;
214 return LoopAction::Terminate(TerminationReason::BudgetExhausted);
215 }
216 }
217 let (_idx, chrom) = self.eval_queue.remove(0);
218 LoopAction::SendPayload(chrom)
219 }
220 Phase::Done => LoopAction::Terminate(TerminationReason::BudgetExhausted),
221 }
222 }
223
224 #[must_use]
226 pub fn suggested_delay_ms(&self) -> u64 {
227 if self.evolution.target_health.in_backoff() {
228 self.evolution.target_health.backoff().as_millis() as u64
229 } else {
230 0
231 }
232 }
233}
234
235impl Default for IntelligenceLoop {
236 fn default() -> Self {
237 Self::new(20)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn new_loop_default() {
247 let il = IntelligenceLoop::default();
248 assert_eq!(il.probes_completed(), 0);
249 assert_eq!(il.feedback_count(), 0);
250 assert!(!il.has_sufficient_data());
251 }
252
253 #[test]
254 fn generate_probes_not_empty() {
255 let il = IntelligenceLoop::default();
256 let probes = il.generate_probes();
257 assert!(!probes.is_empty());
258 }
259
260 #[test]
261 fn generate_quick_probes_smaller() {
262 let _il = IntelligenceLoop::default();
263 let full = generate_probes();
264 let quick = generate_quick_probes();
265 assert!(quick.len() < full.len());
266 }
267
268 #[test]
269 fn record_probe_increments() {
270 let mut il = IntelligenceLoop::default();
271 let probes = il.generate_quick_probes();
272 il.record_probe(&probes[0], true);
273 assert_eq!(il.probes_completed(), 1);
274 }
275
276 #[test]
277 fn sufficient_data_after_min_probes() {
278 let mut il = IntelligenceLoop::with_budget(10, 5, Budget::default());
279 let probes = il.generate_probes();
280 for (i, probe) in probes.iter().enumerate() {
281 il.record_probe(probe, i % 3 == 0);
282 if i >= 4 {
283 break;
284 }
285 }
286 assert!(il.has_sufficient_data());
287 }
288
289 #[test]
290 fn evolution_feedback_loop() {
291 let mut il = IntelligenceLoop::new(10);
292 if let Some((idx, _)) = il.next_candidate() {
293 il.record_feedback(idx, true);
294 assert_eq!(il.feedback_count(), 1);
295 }
296 }
297
298 #[test]
299 fn evolve_doesnt_panic() {
300 let mut il = IntelligenceLoop::new(10);
301 for _ in 0..5 {
302 if let Some((idx, _)) = il.next_candidate() {
303 il.record_feedback(idx, true);
304 }
305 }
306 il.evolve();
307 assert!(il.next_candidate().is_some());
308 }
309
310 #[test]
311 fn waf_report_not_empty_after_probes() {
312 let mut il = IntelligenceLoop::default();
313 let probes = il.generate_quick_probes();
314 for probe in &probes {
315 il.record_probe(probe, true);
316 }
317 let report = il.waf_report();
318 assert!(!report.is_empty());
319 }
320
321 #[test]
322 fn suggested_evasions_from_differential() {
323 let mut il = IntelligenceLoop::default();
324 let probes = generate_probes();
325 for probe in &probes {
326 let is_sql = format!("{:?}", probe.tests).contains("Sql");
327 il.record_probe(probe, is_sql);
328 }
329 let suggestions = il.suggested_evasions();
330 assert!(!suggestions.is_empty());
331 }
332
333 #[test]
334 fn diversity_score_valid_range() {
335 let il = IntelligenceLoop::new(10);
336 let score = il.diversity();
337 assert!((0.0..=1.0).contains(&score));
338 }
339
340 #[test]
341 fn step_state_machine_transitions() {
342 let mut il = IntelligenceLoop::with_budget(10, 2, Budget::default());
343 let action = il.step(Feedback::Passed);
345 assert!(matches!(action, LoopAction::SendProbe(_)));
346
347 il.record_probe(&generate_probes()[0], true);
348 let action2 = il.step(Feedback::Blocked);
349 assert!(matches!(action2, LoopAction::SendProbe(_)));
350
351 il.record_probe(&generate_probes()[1], false);
352 let action3 = il.step(Feedback::Passed);
354 assert!(matches!(action3, LoopAction::SendPayload(_)));
355 }
356
357 #[test]
358 fn step_terminates_on_target_error() {
359 let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
360 for _ in 0..10 {
362 if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
363 let term = il.step(Feedback::TargetError("503".into()));
365 assert!(matches!(
367 term,
368 LoopAction::SendPayload(_) | LoopAction::Terminate(_)
369 ));
370 return;
371 }
372 }
373 }
374
375 #[test]
376 fn step_terminates_when_budget_exhausted() {
377 let mut il = IntelligenceLoop::with_budget(
378 5,
379 0,
380 Budget {
381 max_requests: 3,
382 ..Budget::default()
383 },
384 );
385 let mut sent = 0;
387 for _ in 0..20 {
388 match il.step(Feedback::Passed) {
389 LoopAction::SendProbe(_)
390 | LoopAction::SendPayload(_)
391 | LoopAction::SaveCheckpoint => {
392 sent += 1;
393 }
394 LoopAction::Terminate(TerminationReason::BudgetExhausted) => {
395 break;
396 }
397 LoopAction::Terminate(other) => {
398 panic!("unexpected termination: {other:?}");
399 }
400 }
401 }
402 assert!(sent <= 5, "sent {sent} requests but budget was 3");
404 }
405
406 #[test]
407 fn suggested_delay_zero_when_healthy() {
408 let il = IntelligenceLoop::default();
409 assert_eq!(il.suggested_delay_ms(), 0);
410 }
411
412 #[test]
413 fn suggested_delay_nonzero_after_target_errors() {
414 let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
415 for _ in 0..50 {
417 if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
418 il.step(Feedback::TargetError("503".into()));
419 }
420 }
421 let delay = il.suggested_delay_ms();
423 let _ = delay;
426 }
427
428 #[test]
429 fn always_blocking_oracle_still_terminates() {
430 let mut il = IntelligenceLoop::with_budget(
433 5,
434 0,
435 Budget {
436 max_requests: 50,
437 max_generations: 10,
438 ..Budget::default()
439 },
440 );
441 let mut iterations = 0;
442 while let LoopAction::SendProbe(_)
443 | LoopAction::SendPayload(_)
444 | LoopAction::SaveCheckpoint = il.step(Feedback::Blocked)
445 {
446 iterations += 1;
447 if iterations > 500 {
448 panic!("engine did not terminate within 500 iterations (budget was 50)");
449 }
450 }
451 }
452}