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 {
184 let _ = self.evolution.record_target_error(msg.clone());
185 if !self.evolution.target_health.is_healthy() {
186 return LoopAction::Terminate(TerminationReason::TargetHealthCritical);
187 }
188 }
190
191 match self.phase {
192 Phase::DifferentialProbing => {
193 if let Feedback::Blocked | Feedback::Passed = feedback {
194 }
196 if self.probe_queue.is_empty() || self.probes_completed >= self.min_probes {
197 self.phase = Phase::Evolution;
198 return self.step(Feedback::Passed); }
200 let probe = self.probe_queue.remove(0);
201 LoopAction::SendProbe(probe)
202 }
203 Phase::Evolution => {
204 if self.eval_queue.is_empty() {
205 let remaining = self
206 .budget
207 .max_requests
208 .saturating_sub(self.evolution.request_count);
209 let batch_size = 4_usize.min(remaining).max(1);
210 self.eval_queue = self.evolution.batch_candidates(batch_size);
211 if self.eval_queue.is_empty() {
212 self.phase = Phase::Done;
213 return LoopAction::Terminate(TerminationReason::BudgetExhausted);
214 }
215 }
216 let (_idx, chrom) = self.eval_queue.remove(0);
217 LoopAction::SendPayload(chrom)
218 }
219 Phase::Done => LoopAction::Terminate(TerminationReason::BudgetExhausted),
220 }
221 }
222
223 #[must_use]
225 pub fn suggested_delay_ms(&self) -> u64 {
226 if self.evolution.target_health.in_backoff() {
227 self.evolution.target_health.backoff().as_millis() as u64
228 } else {
229 0
230 }
231 }
232}
233
234impl Default for IntelligenceLoop {
235 fn default() -> Self {
236 Self::new(20)
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn new_loop_default() {
246 let il = IntelligenceLoop::default();
247 assert_eq!(il.probes_completed(), 0);
248 assert_eq!(il.feedback_count(), 0);
249 assert!(!il.has_sufficient_data());
250 }
251
252 #[test]
253 fn generate_probes_not_empty() {
254 let il = IntelligenceLoop::default();
255 let probes = il.generate_probes();
256 assert!(!probes.is_empty());
257 }
258
259 #[test]
260 fn generate_quick_probes_smaller() {
261 let _il = IntelligenceLoop::default();
262 let full = generate_probes();
263 let quick = generate_quick_probes();
264 assert!(quick.len() < full.len());
265 }
266
267 #[test]
268 fn record_probe_increments() {
269 let mut il = IntelligenceLoop::default();
270 let probes = il.generate_quick_probes();
271 il.record_probe(&probes[0], true);
272 assert_eq!(il.probes_completed(), 1);
273 }
274
275 #[test]
276 fn sufficient_data_after_min_probes() {
277 let mut il = IntelligenceLoop::with_budget(10, 5, Budget::default());
278 let probes = il.generate_probes();
279 for (i, probe) in probes.iter().enumerate() {
280 il.record_probe(probe, i % 3 == 0);
281 if i >= 4 {
282 break;
283 }
284 }
285 assert!(il.has_sufficient_data());
286 }
287
288 #[test]
289 fn evolution_feedback_loop() {
290 let mut il = IntelligenceLoop::new(10);
291 if let Some((idx, _)) = il.next_candidate() {
292 il.record_feedback(idx, true);
293 assert_eq!(il.feedback_count(), 1);
294 }
295 }
296
297 #[test]
298 fn evolve_doesnt_panic() {
299 let mut il = IntelligenceLoop::new(10);
300 for _ in 0..5 {
301 if let Some((idx, _)) = il.next_candidate() {
302 il.record_feedback(idx, true);
303 }
304 }
305 il.evolve();
306 assert!(il.next_candidate().is_some());
307 }
308
309 #[test]
310 fn waf_report_not_empty_after_probes() {
311 let mut il = IntelligenceLoop::default();
312 let probes = il.generate_quick_probes();
313 for probe in &probes {
314 il.record_probe(probe, true);
315 }
316 let report = il.waf_report();
317 assert!(!report.is_empty());
318 }
319
320 #[test]
321 fn suggested_evasions_from_differential() {
322 let mut il = IntelligenceLoop::default();
323 let probes = generate_probes();
324 for probe in &probes {
325 let is_sql = format!("{:?}", probe.tests).contains("Sql");
326 il.record_probe(probe, is_sql);
327 }
328 let suggestions = il.suggested_evasions();
329 assert!(!suggestions.is_empty());
330 }
331
332 #[test]
333 fn diversity_score_valid_range() {
334 let il = IntelligenceLoop::new(10);
335 let score = il.diversity();
336 assert!((0.0..=1.0).contains(&score));
337 }
338
339 #[test]
340 fn step_state_machine_transitions() {
341 let mut il = IntelligenceLoop::with_budget(10, 2, Budget::default());
342 let action = il.step(Feedback::Passed);
344 assert!(matches!(action, LoopAction::SendProbe(_)));
345
346 il.record_probe(&generate_probes()[0], true);
347 let action2 = il.step(Feedback::Blocked);
348 assert!(matches!(action2, LoopAction::SendProbe(_)));
349
350 il.record_probe(&generate_probes()[1], false);
351 let action3 = il.step(Feedback::Passed);
353 assert!(matches!(action3, LoopAction::SendPayload(_)));
354 }
355
356 #[test]
357 fn step_terminates_on_target_error() {
358 let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
359 for _ in 0..10 {
361 if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
362 let term = il.step(Feedback::TargetError("503".into()));
364 assert!(matches!(
366 term,
367 LoopAction::SendPayload(_) | LoopAction::Terminate(_)
368 ));
369 return;
370 }
371 }
372 }
373
374 #[test]
375 fn step_terminates_when_budget_exhausted() {
376 let mut il = IntelligenceLoop::with_budget(5, 0, Budget {
377 max_requests: 3,
378 ..Budget::default()
379 });
380 let mut sent = 0;
382 for _ in 0..20 {
383 match il.step(Feedback::Passed) {
384 LoopAction::SendProbe(_) | LoopAction::SendPayload(_) | LoopAction::SaveCheckpoint => {
385 sent += 1;
386 }
387 LoopAction::Terminate(TerminationReason::BudgetExhausted) => {
388 break;
389 }
390 LoopAction::Terminate(other) => {
391 panic!("unexpected termination: {other:?}");
392 }
393 }
394 }
395 assert!(sent <= 5, "sent {sent} requests but budget was 3");
397 }
398
399 #[test]
400 fn suggested_delay_zero_when_healthy() {
401 let il = IntelligenceLoop::default();
402 assert_eq!(il.suggested_delay_ms(), 0);
403 }
404
405 #[test]
406 fn suggested_delay_nonzero_after_target_errors() {
407 let mut il = IntelligenceLoop::with_budget(10, 0, Budget::default());
408 for _ in 0..50 {
410 if let LoopAction::SendPayload(_) = il.step(Feedback::Passed) {
411 il.step(Feedback::TargetError("503".into()));
412 }
413 }
414 let delay = il.suggested_delay_ms();
416 let _ = delay;
419 }
420
421 #[test]
422 fn always_blocking_oracle_still_terminates() {
423 let mut il = IntelligenceLoop::with_budget(
426 5,
427 0,
428 Budget {
429 max_requests: 50,
430 max_generations: 10,
431 ..Budget::default()
432 },
433 );
434 let mut iterations = 0;
435 while let LoopAction::SendProbe(_) | LoopAction::SendPayload(_) | LoopAction::SaveCheckpoint = il.step(Feedback::Blocked) {
436 iterations += 1;
437 if iterations > 500 {
438 panic!("engine did not terminate within 500 iterations (budget was 50)");
439 }
440 }
441 }
442}