Skip to main content

luft_runtime/
converge.rs

1#![allow(dead_code)]
2//! Converge — adversarial verification and convergence logic.
3//!
4//! This module implements the core dynamic workflow convergence algorithm:
5//! 1. Producer agents generate findings from items
6//! 2. Adversarial agents attempt to refute those findings
7//! 3. Voting determines surviving findings
8//! 4. Iteration continues until convergence or max rounds
9//!
10//! This is the key feature that distinguishes Dynamic Workflows from simple
11//! parallel execution: agents verify each other's work before the result
12//! reaches the user.
13
14use crate::error::ScriptError;
15use crate::sdk::SdkContext;
16use luft_core::contract::backend::{AgentStatus, RunContext};
17use luft_core::contract::event::AgentEvent;
18use luft_core::contract::finding::Finding;
19use luft_core::Scheduler;
20use mlua::{Lua, Table, Value};
21use serde::{Deserialize, Serialize};
22use std::sync::atomic::Ordering;
23use std::sync::Arc;
24
25/// Configuration for convergence verification.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ConvergeConfig {
28    /// Whether to enable adversarial verification.
29    pub adversarial: bool,
30    /// Voting threshold (0.0-1.0). Findings with approval >= threshold survive.
31    pub vote_threshold: f32,
32    /// Maximum number of verification rounds.
33    pub max_rounds: u32,
34    /// Number of producer agents per round.
35    pub producers_per_item: u32,
36    /// Number of adversarial agents per finding.
37    pub adversaries_per_finding: u32,
38    /// Model to use for agents.
39    pub model: Option<String>,
40}
41
42impl Default for ConvergeConfig {
43    fn default() -> Self {
44        Self {
45            adversarial: true,
46            vote_threshold: 0.7,
47            max_rounds: 3,
48            producers_per_item: 1,
49            adversaries_per_finding: 1,
50            model: None,
51        }
52    }
53}
54
55/// Result of a converge operation.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ConvergeResult {
58    /// Items that survived verification.
59    pub surviving_items: Vec<serde_json::Value>,
60    /// All findings generated during verification.
61    pub findings: Vec<Finding>,
62    /// Number of rounds executed.
63    pub rounds: u32,
64    /// Whether convergence was achieved.
65    pub converged: bool,
66    /// Voting statistics per round.
67    pub round_stats: Vec<RoundStats>,
68}
69
70/// Statistics for a single verification round.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct RoundStats {
73    pub round: u32,
74    pub items_input: usize,
75    pub findings_generated: usize,
76    pub findings_survived: usize,
77    pub findings_refuted: usize,
78    pub approval_rate: f32,
79}
80
81/// Internal state during convergence iteration.
82struct ConvergeState {
83    items: Vec<serde_json::Value>,
84    findings: Vec<Finding>,
85    round_stats: Vec<RoundStats>,
86    converged: bool,
87}
88
89/// Execute adversarial convergence on a list of items.
90///
91/// This function:
92/// 1. Generates findings for each item using producer agents
93/// 2. Has adversarial agents attempt to refute each finding
94/// 3. Votes on findings; survivors proceed to next round
95/// 4. Repeats until convergence or max rounds
96pub async fn execute_convergence(
97    items: Vec<serde_json::Value>,
98    producer_prompt: &str,
99    adversary_prompt: &str,
100    config: ConvergeConfig,
101    scheduler: &Arc<Scheduler>,
102    run_ctx: &RunContext,
103) -> Result<ConvergeResult, ScriptError> {
104    if items.is_empty() {
105        tracing::debug!("converge: no items, returning empty");
106        return Ok(ConvergeResult {
107            surviving_items: vec![],
108            findings: vec![],
109            rounds: 0,
110            converged: true,
111            round_stats: vec![],
112        });
113    }
114
115    tracing::info!(
116        n_items = items.len(),
117        max_rounds = config.max_rounds,
118        adversarial = config.adversarial,
119        "converge started"
120    );
121
122    let mut state = ConvergeState {
123        items,
124        findings: vec![],
125        round_stats: vec![],
126        converged: false,
127    };
128
129    for round in 1..=config.max_rounds {
130        let items_count = state.items.len();
131        tracing::info!(round, items_count, "converge round started");
132        let round_input = state.items.clone();
133
134        // Phase 1: Producer agents generate findings
135        let (findings, _producer_stats) = generate_findings(
136            &round_input,
137            producer_prompt,
138            config.producers_per_item,
139            config.model.clone(),
140            scheduler,
141            run_ctx,
142        )
143        .await;
144
145        let current_findings = findings.clone();
146        state.findings.extend(current_findings);
147
148        if findings.is_empty() {
149            tracing::info!(round, "converge: no findings generated, ending");
150            break;
151        }
152
153        // Phase 2: Adversarial agents refute findings (if enabled)
154        let (surviving_findings, vote_stats) = if config.adversarial {
155            tracing::debug!(
156                round,
157                n_findings = findings.len(),
158                adversaries = config.adversaries_per_finding,
159                "adversarial verification started"
160            );
161            verify_findings(
162                &findings,
163                adversary_prompt,
164                config.adversaries_per_finding,
165                config.vote_threshold,
166                config.model.clone(),
167                scheduler,
168                run_ctx,
169            )
170            .await
171        } else {
172            (findings.clone(), VoteStats::default())
173        };
174
175        // Update round statistics
176        let round_stats = RoundStats {
177            round,
178            items_input: items_count,
179            findings_generated: findings.len(),
180            findings_survived: surviving_findings.len(),
181            findings_refuted: findings.len() - surviving_findings.len(),
182            approval_rate: vote_stats.approval_rate,
183        };
184        tracing::info!(
185            round,
186            generated = findings.len(),
187            survived = surviving_findings.len(),
188            refuted = findings.len() - surviving_findings.len(),
189            approval_rate = vote_stats.approval_rate,
190            "converge round finished"
191        );
192        state.round_stats.push(round_stats);
193
194        // Check for convergence
195        if surviving_findings.is_empty() {
196            tracing::info!(round, "converge: all findings refuted, converged");
197            state.converged = true;
198            break;
199        }
200
201        // Check if items unchanged (full convergence)
202        if surviving_findings.len() == findings.len() && round > 1 {
203            tracing::info!(round, "converge: no findings refuted, full convergence");
204            state.converged = true;
205            break;
206        }
207
208        // For next round, use surviving findings as items
209        state.items = surviving_findings
210            .into_iter()
211            .map(|f| {
212                serde_json::json!({
213                    "kind": f.kind,
214                    "severity": format!("{:?}", f.severity).to_lowercase(),
215                    "title": f.title,
216                    "detail": f.detail,
217                    "location": f.location,
218                    "evidence": f.evidence,
219                    "data": f.data
220                })
221            })
222            .collect();
223    }
224
225    tracing::info!(
226        rounds = state.round_stats.len(),
227        converged = state.converged,
228        surviving = state.items.len(),
229        total_findings = state.findings.len(),
230        "converge finished"
231    );
232    Ok(ConvergeResult {
233        surviving_items: state.items,
234        findings: state.findings,
235        rounds: state.round_stats.len() as u32,
236        converged: state.converged,
237        round_stats: state.round_stats,
238    })
239}
240
241/// Generate findings for items using producer agents (parallel execution).
242async fn generate_findings(
243    items: &[serde_json::Value],
244    prompt_template: &str,
245    producers_per_item: u32,
246    model: Option<String>,
247    scheduler: &Arc<Scheduler>,
248    run_ctx: &RunContext,
249) -> (Vec<Finding>, ProducerStats) {
250    // Build all tasks for parallel execution
251    let mut tasks = Vec::new();
252    for item in items {
253        for _ in 0..producers_per_item {
254            let prompt =
255                prompt_template.replace("{item}", &serde_json::to_string(item).unwrap_or_default());
256            let agent_id = uuid::Uuid::now_v7();
257            let task = luft_core::contract::backend::AgentTask {
258                agent_id,
259                phase_id: 2, // Converge phase
260                prompt,
261                model: model.clone(),
262                description: None,
263                role: Some("producer".to_string()),
264                name: None,
265                agent_seq: 0,
266                allowlist: None,
267                workdir: std::path::PathBuf::from("."),
268                mcp_endpoint: None,
269                timeout: None,
270                output_schema: None,
271        workdir_override: None,
272        thread_id: None,
273            };
274            tasks.push((task, None::<String>));
275        }
276    }
277
278    // Run all producers in parallel
279    let results = scheduler.run_parallel(run_ctx.run_id, tasks).await;
280    let agents_run = results.len();
281    tracing::debug!(agents_run, "producer agents completed");
282    let mut all_findings = Vec::new();
283
284    for result in results.into_iter().flatten() {
285        all_findings.extend(result.findings);
286    }
287
288    let stats = ProducerStats {
289        items_processed: items.len(),
290        agents_run,
291        findings_generated: all_findings.len(),
292    };
293
294    (all_findings, stats)
295}
296
297/// Verify findings using adversarial agents.
298async fn verify_findings(
299    findings: &[Finding],
300    prompt_template: &str,
301    adversaries_per_finding: u32,
302    vote_threshold: f32,
303    model: Option<String>,
304    scheduler: &Arc<Scheduler>,
305    run_ctx: &RunContext,
306) -> (Vec<Finding>, VoteStats) {
307    let mut votes: Vec<(Finding, usize)> = findings.iter().map(|f| (f.clone(), 0)).collect();
308    let total_votes_possible = findings.len() * adversaries_per_finding as usize;
309
310    for finding in findings {
311        let mut approval_count = 0usize;
312
313        for _ in 0..adversaries_per_finding {
314            let prompt = prompt_template.replace(
315                "{finding}",
316                &serde_json::to_string(finding).unwrap_or_default(),
317            );
318            let agent_id = uuid::Uuid::now_v7();
319
320            let task = luft_core::contract::backend::AgentTask {
321                agent_id,
322                phase_id: 2,
323                prompt,
324                model: model.clone(),
325                description: None,
326                role: Some("adversary".to_string()),
327                name: None,
328                agent_seq: 0,
329                allowlist: None,
330                workdir: std::path::PathBuf::from("."),
331                mcp_endpoint: None,
332                timeout: None,
333                output_schema: None,
334        workdir_override: None,
335        thread_id: None,
336            };
337
338            let result = scheduler.run_agent(run_ctx.run_id, task, None).await;
339
340            if let Ok(r) = result {
341                if r.status == AgentStatus::Ok {
342                    tracing::trace!(%agent_id, "adversary approved finding");
343                    approval_count += 1;
344                } else {
345                    tracing::trace!(%agent_id, ?r.status, "adversary rejected finding");
346                }
347            }
348        }
349
350        if let Some((_, count)) = votes.iter_mut().find(|(f, _)| f.title == finding.title) {
351            *count = approval_count;
352        }
353    }
354
355    let approval_rate = if total_votes_possible > 0 {
356        votes.iter().map(|(_, c)| *c).sum::<usize>() as f32 / total_votes_possible as f32
357    } else {
358        0.0
359    };
360
361    let surviving: Vec<Finding> = votes
362        .into_iter()
363        .filter(|(_, count)| {
364            let threshold = (count * 100) / (adversaries_per_finding as usize).max(1);
365            threshold as f32 >= vote_threshold * 100.0
366        })
367        .map(|(f, _)| f)
368        .collect();
369
370    tracing::debug!(
371        surviving = surviving.len(),
372        refuted = findings.len() - surviving.len(),
373        approval_rate,
374        "adversarial voting completed"
375    );
376
377    let stats = VoteStats { approval_rate };
378
379    (surviving, stats)
380}
381
382#[derive(Debug, Default)]
383#[allow(dead_code)] // diagnostic accounting; retained for future progress reporting
384struct ProducerStats {
385    items_processed: usize,
386    agents_run: usize,
387    findings_generated: usize,
388}
389
390#[derive(Debug, Default)]
391struct VoteStats {
392    approval_rate: f32,
393}
394
395// ============================================================================
396// Lua SDK Bridge
397// ============================================================================
398
399/// Extract a string from mlua::String, converting to owned String to avoid borrow issues.
400fn extract_string(s: mlua::String) -> Option<String> {
401    s.to_str().ok().map(|s| s.to_string())
402}
403
404/// Register the converge SDK function in Lua.
405///
406/// `handle` is the tokio runtime handle used to block on the async scheduler.
407/// It must be called from a blocking execution context (see `sandbox`).
408pub fn register_converge_sdk(lua: &Lua, cx: &SdkContext) -> mlua::Result<()> {
409    let globals = lua.globals();
410    let sched = cx.scheduler.clone();
411    let rc = cx.run_ctx.clone();
412    let handle = cx.handle.clone();
413    let events = cx.events();
414    let run_id = cx.run_id();
415    let phase_counter = cx.phase_counter.clone();
416    let span_counter = cx.span_counter.clone();
417
418    let converge_fn = lua.create_function(move |_lua, (items, options): (Table, Table)| {
419        let scheduler = sched.clone();
420        let run_ctx = rc.clone();
421        let handle = handle.clone();
422        // Parse options
423        let config = parse_converge_options(&options);
424        tracing::debug!(
425            adversarial = config.adversarial,
426            max_rounds = config.max_rounds,
427            "converge SDK invoked"
428        );
429        let phase_id = phase_counter.load(Ordering::Relaxed);
430        let span_id = span_counter.fetch_add(1, Ordering::Relaxed);
431        let max_rounds = config.max_rounds;
432
433        let producer_prompt = options
434            .get::<mlua::String>("producer_prompt")
435            .ok()
436            .and_then(extract_string)
437            .unwrap_or_else(|| {
438                "Analyze the following item and report any findings: {item}".to_string()
439            });
440
441        let adversary_prompt = options
442            .get::<mlua::String>("adversary_prompt")
443            .ok()
444            .and_then(extract_string)
445            .unwrap_or_else(|| {
446                "Review this finding and determine if it is valid or should be refuted: {finding}"
447                    .to_string()
448            });
449
450        // Convert items to Vec
451        let items_vec: Vec<serde_json::Value> = items
452            .sequence_values()
453            .filter_map(|v: mlua::Result<Value>| v.ok())
454            .filter_map(|v| lua_value_to_json(&v).ok())
455            .collect();
456
457        let _ = events.send(AgentEvent::ConvergeStarted {
458            run_id,
459            phase_id,
460            span_id,
461            items: items_vec.len(),
462            max_rounds,
463        });
464        let t0 = std::time::Instant::now();
465
466        // Execute convergence by blocking on the shared scheduler runtime.
467        let result = handle.block_on(execute_convergence(
468            items_vec,
469            &producer_prompt,
470            &adversary_prompt,
471            config,
472            &scheduler,
473            &run_ctx,
474        ));
475        let elapsed_ms = t0.elapsed().as_millis() as u64;
476
477        match result {
478            Ok(ref res) => {
479                tracing::info!(
480                    rounds = res.rounds,
481                    converged = res.converged,
482                    surviving = res.surviving_items.len(),
483                    elapsed_ms,
484                    "converge SDK completed"
485                );
486            }
487            Err(ref e) => {
488                tracing::error!(error = %e, elapsed_ms, "converge SDK failed");
489            }
490        }
491        match result {
492            Ok(res) => {
493                let _ = events.send(AgentEvent::ConvergeDone {
494                    run_id,
495                    phase_id,
496                    span_id,
497                    rounds: res.rounds,
498                    converged: res.converged,
499                    surviving: res.surviving_items.len(),
500                    result: serde_json::json!({
501                        "surviving": res.surviving_items,
502                        "rounds": res.rounds,
503                        "converged": res.converged,
504                        "findings": res.findings,
505                    }),
506                    elapsed_ms,
507                    error: None,
508                });
509                let result_table = _lua.create_table()?;
510                let surviving = _lua.create_table()?;
511                for (i, item) in res.surviving_items.iter().enumerate() {
512                    let lua_val = json_to_lua_value(_lua, item.clone())?;
513                    surviving.set(i + 1, lua_val)?;
514                }
515                result_table.set("surviving", surviving)?;
516                result_table.set("rounds", res.rounds)?;
517                result_table.set("converged", res.converged)?;
518
519                let findings_table = _lua.create_table()?;
520                for (i, finding) in res.findings.iter().enumerate() {
521                    let ft = _lua.create_table()?;
522                    ft.set("kind", finding.kind.as_str())?;
523                    ft.set("severity", format!("{:?}", finding.severity).to_lowercase())?;
524                    ft.set("title", finding.title.as_str())?;
525                    ft.set("detail", finding.detail.as_str())?;
526                    findings_table.set(i + 1, ft)?;
527                }
528                result_table.set("findings", findings_table)?;
529
530                Ok(result_table)
531            }
532            Err(e) => {
533                let _ = events.send(AgentEvent::ConvergeDone {
534                    run_id,
535                    phase_id,
536                    span_id,
537                    rounds: 0,
538                    converged: false,
539                    surviving: 0,
540                    result: serde_json::Value::Null,
541                    elapsed_ms,
542                    error: Some(e.to_string()),
543                });
544                Err(mlua::Error::RuntimeError(format!("converge error: {}", e)))
545            }
546        }
547    })?;
548
549    globals.set("converge", converge_fn)?;
550    Ok(())
551}
552
553/// Parse converge options from a Lua table.
554fn parse_converge_options(options: &Table) -> ConvergeConfig {
555    let mut config = ConvergeConfig::default();
556
557    if let Ok(adversarial) = options.get::<mlua::String>("adversarial") {
558        let s = extract_string(adversarial).unwrap_or_else(|| "true".to_string());
559        config.adversarial = s != "false";
560    }
561    if let Ok(threshold) = options.get::<mlua::Number>("vote_threshold") {
562        config.vote_threshold = threshold as f32;
563    }
564    if let Ok(max_rounds) = options.get::<mlua::Integer>("max_rounds") {
565        config.max_rounds = max_rounds as u32;
566    }
567    if let Ok(producers) = options.get::<mlua::Integer>("producers") {
568        config.producers_per_item = producers as u32;
569    }
570    if let Ok(adversaries) = options.get::<mlua::Integer>("adversaries") {
571        config.adversaries_per_finding = adversaries as u32;
572    }
573    if let Ok(model) = options.get::<mlua::String>("model") {
574        let s = extract_string(model).unwrap_or_default();
575        config.model = if s.is_empty() { None } else { Some(s) };
576    }
577
578    config
579}
580
581/// Convert a Lua Value to serde_json::Value.
582fn lua_value_to_json(value: &Value) -> Result<serde_json::Value, mlua::Error> {
583    match value {
584        Value::Nil => Ok(serde_json::Value::Null),
585        Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
586        Value::Integer(i) => Ok(serde_json::Value::Number(serde_json::Number::from(*i))),
587        Value::Number(n) => {
588            if let Some(n) = serde_json::Number::from_f64(*n) {
589                Ok(serde_json::Value::Number(n))
590            } else {
591                Ok(serde_json::Value::Null)
592            }
593        }
594        Value::String(s) => {
595            let owned = s.clone();
596            let s = match owned.to_str() {
597                Ok(s) => s.to_string(),
598                Err(_) => return Ok(serde_json::Value::Null),
599            };
600            Ok(serde_json::Value::String(s))
601        }
602        Value::Table(t) => {
603            // Try as array first
604            let len = t.len().unwrap_or(0);
605            if len > 0 {
606                let arr: Vec<serde_json::Value> = t
607                    .sequence_values()
608                    .filter_map(|v| v.ok())
609                    .filter_map(|v| lua_value_to_json(&v).ok())
610                    .collect();
611                if !arr.is_empty() {
612                    return Ok(serde_json::Value::Array(arr));
613                }
614            }
615
616            // Fall back to object
617            let mut map = serde_json::Map::new();
618            for (k, v) in t.pairs::<Value, Value>().flatten() {
619                let key = match k {
620                    Value::String(s) => s
621                        .clone()
622                        .to_str()
623                        .map(|s| s.to_string())
624                        .unwrap_or_default(),
625                    Value::Integer(i) => i.to_string(),
626                    _ => continue,
627                };
628                if let Ok(v) = lua_value_to_json(&v) {
629                    map.insert(key, v);
630                }
631            }
632            Ok(serde_json::Value::Object(map))
633        }
634        _ => Ok(serde_json::Value::Null),
635    }
636}
637
638/// Convert serde_json::Value to a Lua Value.
639fn json_to_lua_value(lua: &Lua, json: serde_json::Value) -> Result<Value, mlua::Error> {
640    match json {
641        serde_json::Value::Null => Ok(Value::Nil),
642        serde_json::Value::Bool(b) => Ok(Value::Boolean(b)),
643        serde_json::Value::Number(n) => {
644            if let Some(i) = n.as_i64() {
645                Ok(Value::Integer(i))
646            } else if let Some(f) = n.as_f64() {
647                Ok(Value::Number(f))
648            } else {
649                Ok(Value::Nil)
650            }
651        }
652        serde_json::Value::String(s) => Ok(Value::String(lua.create_string(&s)?)),
653        serde_json::Value::Array(arr) => {
654            let t = lua.create_table()?;
655            for (i, v) in arr.into_iter().enumerate() {
656                t.set(i + 1, json_to_lua_value(lua, v)?)?;
657            }
658            Ok(Value::Table(t))
659        }
660        serde_json::Value::Object(map) => {
661            let t = lua.create_table()?;
662            for (k, v) in map {
663                t.set(k, json_to_lua_value(lua, v)?)?;
664            }
665            Ok(Value::Table(t))
666        }
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use crate::sdk::ReportSink;
674    use async_trait::async_trait;
675    use luft_core::contract::backend::{
676        AgentBackend, AgentCapabilities, AgentResult, BackendError, LogRef,
677    };
678    use luft_core::contract::finding::{Location, Severity};
679    use luft_core::contract::ids::TokenUsage;
680    use luft_core::scheduler::{BackendRegistry, RetryPolicy, SchedulerConfig};
681    use luft_core::{AgentTask, MockBackend, MockBehavior};
682    use std::sync::Arc;
683    use std::time::Duration;
684    use tokio::sync::broadcast;
685    use tokio_util::sync::CancellationToken;
686    use uuid::Uuid;
687
688    // ── Helper: create a sample Finding ──────────────────────────
689
690    fn sample_finding(title: &str) -> Finding {
691        Finding {
692            kind: "test".into(),
693            severity: Severity::Info,
694            title: title.into(),
695            detail: "A detailed description".into(),
696            location: Some(Location {
697                file: "src/main.rs".into(),
698                line: Some(42),
699            }),
700            evidence: vec!["line 42: suspect code".into()],
701            data: serde_json::json!({"extra": "info"}),
702        }
703    }
704
705    fn default_finding() -> Finding {
706        sample_finding("Test Finding")
707    }
708
709    // ── Helper: create a quick scheduler for converge tests ──────
710
711    fn converge_scheduler(backend: Arc<dyn AgentBackend>) -> Arc<Scheduler> {
712        let config = SchedulerConfig {
713            max_concurrency: 4,
714            quota_per_run: 1000,
715            retry: RetryPolicy::default(),
716        };
717        let registry = BackendRegistry::new().with(backend);
718        Scheduler::new(config, registry, None)
719    }
720
721    fn test_run_ctx(scheduler: &Arc<Scheduler>) -> (RunContext, broadcast::Receiver<AgentEvent>) {
722        let run_id = Uuid::now_v7();
723        let rx = scheduler.init_run(run_id, 64);
724        let (tx, _rx2) = broadcast::channel(64);
725        let ctx = RunContext {
726            run_id,
727            cancel: CancellationToken::new(),
728            events: tx,
729        };
730        (ctx, rx)
731    }
732
733    // ═══════════════════════════════════════════════════════════════
734    // ConvergeConfig
735    // ═══════════════════════════════════════════════════════════════
736
737    #[test]
738    fn test_default_config() {
739        let c = ConvergeConfig::default();
740        assert!(c.adversarial);
741        assert!((c.vote_threshold - 0.7).abs() < f32::EPSILON);
742        assert_eq!(c.max_rounds, 3);
743        assert_eq!(c.producers_per_item, 1);
744        assert_eq!(c.adversaries_per_finding, 1);
745        assert!(c.model.is_none());
746    }
747
748    #[test]
749    fn test_converge_config_debug_clone_serialize() {
750        let c = ConvergeConfig::default();
751        let _ = format!("{:?}", c);
752        let _ = c.clone();
753        let json = serde_json::to_string(&c).unwrap();
754        let back: ConvergeConfig = serde_json::from_str(&json).unwrap();
755        assert_eq!(c.max_rounds, back.max_rounds);
756    }
757
758    // ═══════════════════════════════════════════════════════════════
759    // parse_converge_options
760    // ═══════════════════════════════════════════════════════════════
761
762    #[test]
763    fn test_parse_options_defaults() {
764        let lua = Lua::new();
765        let t = lua.create_table().unwrap();
766        let cfg = parse_converge_options(&t);
767        assert!(cfg.adversarial);
768        assert!((cfg.vote_threshold - 0.7).abs() < f32::EPSILON);
769        assert_eq!(cfg.max_rounds, 3);
770        assert_eq!(cfg.producers_per_item, 1);
771        assert_eq!(cfg.adversaries_per_finding, 1);
772        assert!(cfg.model.is_none());
773    }
774
775    #[test]
776    fn test_parse_options_all_fields() {
777        let lua = Lua::new();
778        let t = lua.create_table().unwrap();
779        t.set("adversarial", "false").unwrap();
780        t.set("vote_threshold", 0.5).unwrap();
781        t.set("max_rounds", 5u32).unwrap();
782        t.set("producers", 2u32).unwrap();
783        t.set("adversaries", 3u32).unwrap();
784        t.set("model", "gpt-4").unwrap();
785        let cfg = parse_converge_options(&t);
786        assert!(!cfg.adversarial);
787        assert!((cfg.vote_threshold - 0.5).abs() < f32::EPSILON);
788        assert_eq!(cfg.max_rounds, 5);
789        assert_eq!(cfg.producers_per_item, 2);
790        assert_eq!(cfg.adversaries_per_finding, 3);
791        assert_eq!(cfg.model.as_deref(), Some("gpt-4"));
792    }
793
794    #[test]
795    fn test_parse_options_adversarial_true_string() {
796        let lua = Lua::new();
797        let t = lua.create_table().unwrap();
798        t.set("adversarial", "true").unwrap();
799        assert!(parse_converge_options(&t).adversarial);
800    }
801
802    #[test]
803    fn test_parse_options_empty_model() {
804        let lua = Lua::new();
805        let t = lua.create_table().unwrap();
806        t.set("model", "").unwrap();
807        assert!(parse_converge_options(&t).model.is_none());
808    }
809
810    #[test]
811    fn test_parse_options_model_some() {
812        let lua = Lua::new();
813        let t = lua.create_table().unwrap();
814        t.set("model", "claude").unwrap();
815        assert_eq!(parse_converge_options(&t).model.as_deref(), Some("claude"));
816    }
817
818    // ═══════════════════════════════════════════════════════════════
819    // extract_string
820    // ═══════════════════════════════════════════════════════════════
821
822    #[test]
823    fn test_extract_string_valid() {
824        let lua = Lua::new();
825        assert_eq!(
826            extract_string(lua.create_string("hello").unwrap()),
827            Some("hello".into())
828        );
829    }
830
831    #[test]
832    fn test_extract_string_empty() {
833        let lua = Lua::new();
834        assert_eq!(
835            extract_string(lua.create_string("").unwrap()),
836            Some("".into())
837        );
838    }
839
840    #[test]
841    fn test_extract_string_invalid_utf8() {
842        let lua = Lua::new();
843        let s = lua.create_string([0xFF, 0xFE, 0x00]).unwrap();
844        assert_eq!(extract_string(s), None);
845    }
846
847    // ═══════════════════════════════════════════════════════════════
848    // lua_value_to_json — every match arm
849    // ═══════════════════════════════════════════════════════════════
850
851    #[test]
852    fn test_lua_value_to_json_nil() {
853        assert_eq!(
854            lua_value_to_json(&Value::Nil).unwrap(),
855            serde_json::Value::Null
856        );
857    }
858
859    #[test]
860    fn test_lua_value_to_json_boolean() {
861        assert_eq!(
862            lua_value_to_json(&Value::Boolean(true)).unwrap(),
863            serde_json::Value::Bool(true)
864        );
865        assert_eq!(
866            lua_value_to_json(&Value::Boolean(false)).unwrap(),
867            serde_json::Value::Bool(false)
868        );
869    }
870
871    #[test]
872    fn test_lua_value_to_json_integer() {
873        assert_eq!(
874            lua_value_to_json(&Value::Integer(42)).unwrap(),
875            serde_json::json!(42)
876        );
877        assert_eq!(
878            lua_value_to_json(&Value::Integer(-5)).unwrap(),
879            serde_json::json!(-5)
880        );
881    }
882
883    #[test]
884    fn test_lua_value_to_json_number() {
885        assert_eq!(
886            lua_value_to_json(&Value::Number(std::f64::consts::PI)).unwrap(),
887            serde_json::json!(std::f64::consts::PI)
888        );
889    }
890
891    #[test]
892    fn test_lua_value_to_json_number_nan() {
893        assert_eq!(
894            lua_value_to_json(&Value::Number(f64::NAN)).unwrap(),
895            serde_json::Value::Null
896        );
897    }
898
899    #[test]
900    fn test_lua_value_to_json_number_infinity() {
901        assert_eq!(
902            lua_value_to_json(&Value::Number(f64::INFINITY)).unwrap(),
903            serde_json::Value::Null
904        );
905        assert_eq!(
906            lua_value_to_json(&Value::Number(f64::NEG_INFINITY)).unwrap(),
907            serde_json::Value::Null
908        );
909    }
910
911    #[test]
912    fn test_lua_value_to_json_string_valid() {
913        let lua = Lua::new();
914        let s = lua.create_string("hello world").unwrap();
915        assert_eq!(
916            lua_value_to_json(&Value::String(s)).unwrap(),
917            serde_json::json!("hello world")
918        );
919    }
920
921    #[test]
922    fn test_lua_value_to_json_string_invalid_utf8() {
923        let lua = Lua::new();
924        let s = lua.create_string([0xFF, 0xFE]).unwrap();
925        assert_eq!(
926            lua_value_to_json(&Value::String(s)).unwrap(),
927            serde_json::Value::Null
928        );
929    }
930
931    #[test]
932    fn test_lua_value_to_json_table_as_array() {
933        let lua = Lua::new();
934        let t = lua.create_table().unwrap();
935        t.set(1, "a").unwrap();
936        t.set(2, "b").unwrap();
937        t.set(3, "c").unwrap();
938        assert_eq!(
939            lua_value_to_json(&Value::Table(t)).unwrap(),
940            serde_json::json!(["a", "b", "c"])
941        );
942    }
943
944    #[test]
945    fn test_lua_value_to_json_table_as_object() {
946        let lua = Lua::new();
947        let t = lua.create_table().unwrap();
948        t.set("name", "test").unwrap();
949        t.set("count", 42).unwrap();
950        let val = lua_value_to_json(&Value::Table(t)).unwrap();
951        let obj = val.as_object().unwrap();
952        assert_eq!(obj["name"], "test");
953        assert_eq!(obj["count"], 42);
954    }
955
956    #[test]
957    fn test_lua_value_to_json_table_empty() {
958        let lua = Lua::new();
959        let t = lua.create_table().unwrap();
960        assert_eq!(
961            lua_value_to_json(&Value::Table(t)).unwrap(),
962            serde_json::json!({})
963        );
964    }
965
966    #[test]
967    fn test_lua_value_to_json_table_nested() {
968        let lua = Lua::new();
969        let inner = lua.create_table().unwrap();
970        inner.set("key", "val").unwrap();
971        let outer = lua.create_table().unwrap();
972        outer.set("nested", inner).unwrap();
973        let val = lua_value_to_json(&Value::Table(outer)).unwrap();
974        assert_eq!(val, serde_json::json!({"nested": {"key": "val"}}));
975    }
976
977    #[test]
978    fn test_lua_value_to_json_table_integer_key_in_object() {
979        let lua = Lua::new();
980        let t = lua.create_table().unwrap();
981        t.set(42, "answer").unwrap();
982        let val = lua_value_to_json(&Value::Table(t)).unwrap();
983        assert_eq!(val, serde_json::json!({"42": "answer"}));
984    }
985
986    #[test]
987    fn test_lua_value_to_json_function_catch_all() {
988        let lua = Lua::new();
989        let f = lua.create_function(|_, ()| Ok(())).unwrap();
990        assert_eq!(
991            lua_value_to_json(&Value::Function(f)).unwrap(),
992            serde_json::Value::Null
993        );
994    }
995
996    #[test]
997    fn test_lua_value_to_json_userdata_catch_all() {
998        let lua = Lua::new();
999        // Thread is a kind of userdata in Lua
1000        let thread = lua
1001            .create_thread(lua.load("return 1").into_function().unwrap())
1002            .unwrap();
1003        let val = Value::Thread(thread);
1004        assert_eq!(lua_value_to_json(&val).unwrap(), serde_json::Value::Null);
1005    }
1006
1007    // ═══════════════════════════════════════════════════════════════
1008    // json_to_lua_value — every match arm
1009    // ═══════════════════════════════════════════════════════════════
1010
1011    #[test]
1012    fn test_json_to_lua_value_null() {
1013        let lua = Lua::new();
1014        assert!(matches!(
1015            json_to_lua_value(&lua, serde_json::Value::Null).unwrap(),
1016            Value::Nil
1017        ));
1018    }
1019
1020    #[test]
1021    fn test_json_to_lua_value_bool() {
1022        let lua = Lua::new();
1023        assert!(matches!(
1024            json_to_lua_value(&lua, serde_json::Value::Bool(true)).unwrap(),
1025            Value::Boolean(true)
1026        ));
1027    }
1028
1029    #[test]
1030    fn test_json_to_lua_value_integer() {
1031        let lua = Lua::new();
1032        assert!(matches!(
1033            json_to_lua_value(&lua, serde_json::json!(42)).unwrap(),
1034            Value::Integer(42)
1035        ));
1036    }
1037
1038    #[test]
1039    fn test_json_to_lua_value_float() {
1040        let lua = Lua::new();
1041        assert!(matches!(
1042            json_to_lua_value(&lua, serde_json::json!(std::f64::consts::PI)).unwrap(),
1043            Value::Number(n) if (n - std::f64::consts::PI).abs() < f64::EPSILON
1044        ));
1045    }
1046
1047    #[test]
1048    fn test_json_to_lua_value_large_number() {
1049        let lua = Lua::new();
1050        // 1e200 fits in f64 but not i64 → should become a Lua number
1051        let val = json_to_lua_value(&lua, serde_json::json!(1e200)).unwrap();
1052        assert!(matches!(val, Value::Number(_)));
1053    }
1054
1055    #[test]
1056    fn test_json_to_lua_value_string() {
1057        let lua = Lua::new();
1058        assert!(matches!(
1059            json_to_lua_value(&lua, serde_json::Value::String("hi".into())).unwrap(),
1060            Value::String(_)
1061        ));
1062    }
1063
1064    #[test]
1065    fn test_json_to_lua_value_array() {
1066        let lua = Lua::new();
1067        let val = json_to_lua_value(&lua, serde_json::json!([1, 2, 3])).unwrap();
1068        assert!(matches!(val, Value::Table(_)));
1069    }
1070
1071    #[test]
1072    fn test_json_to_lua_value_empty_array() {
1073        let lua = Lua::new();
1074        let val = json_to_lua_value(&lua, serde_json::json!([])).unwrap();
1075        assert!(matches!(val, Value::Table(_)));
1076    }
1077
1078    #[test]
1079    fn test_json_to_lua_value_object() {
1080        let lua = Lua::new();
1081        let val = json_to_lua_value(&lua, serde_json::json!({"a": 1})).unwrap();
1082        assert!(matches!(val, Value::Table(_)));
1083    }
1084
1085    #[test]
1086    fn test_json_to_lua_value_empty_object() {
1087        let lua = Lua::new();
1088        let val = json_to_lua_value(&lua, serde_json::json!({})).unwrap();
1089        assert!(matches!(val, Value::Table(_)));
1090    }
1091
1092    #[test]
1093    fn test_json_to_lua_value_nested() {
1094        let lua = Lua::new();
1095        let val = json_to_lua_value(&lua, serde_json::json!({"a": {"b": [1, 2, 3]}})).unwrap();
1096        assert!(matches!(val, Value::Table(_)));
1097    }
1098
1099    // ═══════════════════════════════════════════════════════════════
1100    // Struct construction & derive traits
1101    // ═══════════════════════════════════════════════════════════════
1102
1103    #[test]
1104    fn test_converge_result_construction() {
1105        let r = ConvergeResult {
1106            surviving_items: vec![serde_json::json!({"k": "v"})],
1107            findings: vec![default_finding()],
1108            rounds: 2,
1109            converged: true,
1110            round_stats: vec![RoundStats {
1111                round: 1,
1112                items_input: 3,
1113                findings_generated: 5,
1114                findings_survived: 4,
1115                findings_refuted: 1,
1116                approval_rate: 0.8,
1117            }],
1118        };
1119        assert_eq!(r.rounds, 2);
1120        assert!(r.converged);
1121        assert_eq!(r.surviving_items.len(), 1);
1122        assert_eq!(r.findings.len(), 1);
1123        assert_eq!(r.round_stats.len(), 1);
1124    }
1125
1126    #[test]
1127    fn test_converge_result_serialize_roundtrip() {
1128        let r = ConvergeResult {
1129            surviving_items: vec![],
1130            findings: vec![],
1131            rounds: 0,
1132            converged: true,
1133            round_stats: vec![],
1134        };
1135        let json = serde_json::to_string(&r).unwrap();
1136        let back: ConvergeResult = serde_json::from_str(&json).unwrap();
1137        assert_eq!(back.rounds, 0);
1138        assert!(back.converged);
1139    }
1140
1141    #[test]
1142    fn test_converge_result_debug_clone() {
1143        let r = ConvergeResult {
1144            surviving_items: vec![],
1145            findings: vec![],
1146            rounds: 1,
1147            converged: false,
1148            round_stats: vec![],
1149        };
1150        let _ = format!("{:?}", r);
1151        let _ = r.clone();
1152    }
1153
1154    #[test]
1155    fn test_round_stats_construction() {
1156        let s = RoundStats {
1157            round: 1,
1158            items_input: 10,
1159            findings_generated: 20,
1160            findings_survived: 15,
1161            findings_refuted: 5,
1162            approval_rate: 0.75,
1163        };
1164        assert_eq!(s.round, 1);
1165        assert_eq!(s.items_input, 10);
1166        assert_eq!(s.findings_generated, 20);
1167        assert_eq!(s.findings_survived, 15);
1168        assert_eq!(s.findings_refuted, 5);
1169        assert!((s.approval_rate - 0.75).abs() < f32::EPSILON);
1170    }
1171
1172    #[test]
1173    fn test_round_stats_serialize_roundtrip() {
1174        let s = RoundStats {
1175            round: 2,
1176            items_input: 5,
1177            findings_generated: 8,
1178            findings_survived: 3,
1179            findings_refuted: 5,
1180            approval_rate: 0.4,
1181        };
1182        let json = serde_json::to_string(&s).unwrap();
1183        let back: RoundStats = serde_json::from_str(&json).unwrap();
1184        assert_eq!(back.round, 2);
1185        assert!((back.approval_rate - 0.4).abs() < f32::EPSILON);
1186    }
1187
1188    // ═══════════════════════════════════════════════════════════════
1189    // execute_convergence — empty items (early return)
1190    // ═══════════════════════════════════════════════════════════════
1191
1192    #[tokio::test]
1193    async fn test_execute_convergence_empty_items() {
1194        let scheduler = converge_scheduler(Arc::new(NoOpBackend));
1195        let (ctx, _rx) = test_run_ctx(&scheduler);
1196
1197        let result = execute_convergence(
1198            vec![],
1199            "producer: {item}",
1200            "adversary: {finding}",
1201            ConvergeConfig::default(),
1202            &scheduler,
1203            &ctx,
1204        )
1205        .await
1206        .unwrap();
1207
1208        assert!(result.surviving_items.is_empty());
1209        assert!(result.findings.is_empty());
1210        assert_eq!(result.rounds, 0);
1211        assert!(result.converged);
1212        assert!(result.round_stats.is_empty());
1213    }
1214
1215    // ═══════════════════════════════════════════════════════════════
1216    // execute_convergence — items but backend returns no findings
1217    // ═══════════════════════════════════════════════════════════════
1218
1219    #[tokio::test]
1220    async fn test_execute_convergence_no_findings() {
1221        let mock = Arc::new(MockBackend::new(
1222            "mock",
1223            vec![MockBehavior::Success {
1224                output: serde_json::Value::Null,
1225                tokens: TokenUsage::default(),
1226                delay: Duration::from_millis(1),
1227            }],
1228        ));
1229        let scheduler = converge_scheduler(mock);
1230        let (ctx, _rx) = test_run_ctx(&scheduler);
1231
1232        let result = execute_convergence(
1233            vec![serde_json::json!({"key": "val"})],
1234            "producer: {item}",
1235            "adversary: {finding}",
1236            ConvergeConfig::default(),
1237            &scheduler,
1238            &ctx,
1239        )
1240        .await
1241        .unwrap();
1242
1243        // No findings generated → break before round stats pushed, so rounds=0.
1244        // Original items survive because state.items was never replaced.
1245        assert_eq!(result.rounds, 0);
1246        assert!(!result.converged);
1247        assert!(result.findings.is_empty());
1248        assert_eq!(
1249            result.surviving_items,
1250            vec![serde_json::json!({"key": "val"})]
1251        );
1252        assert!(result.round_stats.is_empty());
1253    }
1254
1255    // ═══════════════════════════════════════════════════════════════
1256    // execute_convergence — findings survive through multiple rounds
1257    // ═══════════════════════════════════════════════════════════════
1258
1259    #[tokio::test]
1260    async fn test_execute_convergence_findings_survive() {
1261        let findings = vec![default_finding()];
1262        let backend = Arc::new(FindingsAlwaysBackend { findings });
1263        let scheduler = converge_scheduler(backend);
1264        let (ctx, _rx) = test_run_ctx(&scheduler);
1265
1266        let result = execute_convergence(
1267            vec![serde_json::json!({"input": "item"})],
1268            "producer: {item}",
1269            "adversary: {finding}",
1270            ConvergeConfig::default(),
1271            &scheduler,
1272            &ctx,
1273        )
1274        .await
1275        .unwrap();
1276
1277        // Round 1: finding generated, survives; Round 2: same finding → full
1278        // convergence detected because surviving == generated && round > 1.
1279        assert_eq!(result.rounds, 2);
1280        assert!(result.converged);
1281        assert!(!result.findings.is_empty());
1282        assert_eq!(result.round_stats.len(), 2);
1283        // Each round should have findings generated and survived
1284        for rs in &result.round_stats {
1285            assert_eq!(rs.findings_generated, 1);
1286            assert_eq!(rs.findings_survived, 1);
1287            assert_eq!(rs.findings_refuted, 0);
1288        }
1289    }
1290
1291    // ═══════════════════════════════════════════════════════════════
1292    // execute_convergence — adversarial disabled
1293    // ═══════════════════════════════════════════════════════════════
1294
1295    #[tokio::test]
1296    async fn test_execute_convergence_non_adversarial() {
1297        let findings = vec![default_finding()];
1298        let backend = Arc::new(FindingsAlwaysBackend { findings });
1299        let scheduler = converge_scheduler(backend);
1300        let (ctx, _rx) = test_run_ctx(&scheduler);
1301
1302        let config = ConvergeConfig {
1303            adversarial: false,
1304            ..ConvergeConfig::default()
1305        };
1306
1307        let result = execute_convergence(
1308            vec![serde_json::json!({"x": 1})],
1309            "producer: {item}",
1310            "adversary: {finding}",
1311            config,
1312            &scheduler,
1313            &ctx,
1314        )
1315        .await
1316        .unwrap();
1317
1318        // With adversarial=false, findings pass through unverified
1319        assert_eq!(result.rounds, 2);
1320        assert!(result.converged);
1321        assert!(!result.findings.is_empty());
1322    }
1323
1324    // ═══════════════════════════════════════════════════════════════
1325    // execute_convergence — all findings refuted
1326    // ═══════════════════════════════════════════════════════════════
1327
1328    #[tokio::test]
1329    async fn test_execute_convergence_all_refuted() {
1330        let findings = vec![default_finding()];
1331        let backend = Arc::new(FindingsRefutingBackend { findings });
1332        let scheduler = converge_scheduler(backend);
1333        let (ctx, _rx) = test_run_ctx(&scheduler);
1334
1335        let result = execute_convergence(
1336            vec![serde_json::json!({"x": 1})],
1337            "producer: {item}",
1338            "adversary: {finding}",
1339            ConvergeConfig::default(),
1340            &scheduler,
1341            &ctx,
1342        )
1343        .await
1344        .unwrap();
1345
1346        // Round 1: finding generated, then refuted → empty surviving → converged
1347        assert_eq!(result.rounds, 1);
1348        assert!(result.converged);
1349        assert_eq!(result.round_stats.len(), 1);
1350        assert_eq!(result.round_stats[0].findings_generated, 1);
1351        assert_eq!(result.round_stats[0].findings_survived, 0);
1352        assert_eq!(result.round_stats[0].findings_refuted, 1);
1353    }
1354
1355    // ═══════════════════════════════════════════════════════════════
1356    // execute_convergence with no adversarial (findings but adversarial disabled)
1357    // ═══════════════════════════════════════════════════════════════
1358
1359    #[tokio::test]
1360    async fn test_execute_convergence_no_adversarial_findings_generated() {
1361        let findings = vec![default_finding()];
1362        let backend = Arc::new(FindingsAlwaysBackend { findings });
1363        let scheduler = converge_scheduler(backend);
1364        let (ctx, _rx) = test_run_ctx(&scheduler);
1365
1366        let config = ConvergeConfig {
1367            adversarial: false,
1368            max_rounds: 1,
1369            ..ConvergeConfig::default()
1370        };
1371
1372        let result = execute_convergence(
1373            vec![serde_json::json!({"x": 1})],
1374            "producer: {item}",
1375            "adversary: {finding}",
1376            config,
1377            &scheduler,
1378            &ctx,
1379        )
1380        .await
1381        .unwrap();
1382
1383        assert_eq!(result.rounds, 1);
1384        // With max_rounds=1, we never hit the full convergence check (round>1)
1385        assert!(!result.converged);
1386        assert_eq!(result.findings.len(), 1);
1387    }
1388
1389    // ═══════════════════════════════════════════════════════════════
1390    // register_converge_sdk — empty items via Lua
1391    // ═══════════════════════════════════════════════════════════════
1392
1393    #[test]
1394    fn test_register_converge_sdk_empty_items() {
1395        let rt = tokio::runtime::Runtime::new().unwrap();
1396        let lua = Lua::new();
1397        let scheduler = converge_scheduler(Arc::new(NoOpBackend));
1398        let run_id = Uuid::now_v7();
1399        let (tx, _rx2) = broadcast::channel(64);
1400        let run_ctx = RunContext {
1401            run_id,
1402            cancel: CancellationToken::new(),
1403            events: tx,
1404        };
1405        let report_sink: ReportSink = Arc::new(std::sync::Mutex::new(None));
1406        let handle = rt.handle().clone();
1407        let cx = SdkContext::new(run_ctx, scheduler, report_sink, None, handle);
1408
1409        register_converge_sdk(&lua, &cx).unwrap();
1410
1411        let globals = lua.globals();
1412        let converge: mlua::Function = globals.get("converge").unwrap();
1413
1414        let items = lua.create_table().unwrap();
1415        let options = lua.create_table().unwrap();
1416        let result: mlua::Table = converge.call((items, options)).unwrap();
1417
1418        let converged: bool = result.get("converged").unwrap();
1419        assert!(converged);
1420        let rounds: u32 = result.get("rounds").unwrap();
1421        assert_eq!(rounds, 0);
1422        let surviving: mlua::Table = result.get("surviving").unwrap();
1423        assert_eq!(surviving.len().unwrap(), 0);
1424        let findings: mlua::Table = result.get("findings").unwrap();
1425        assert_eq!(findings.len().unwrap(), 0);
1426    }
1427
1428    #[test]
1429    fn test_register_converge_sdk_with_items() {
1430        let rt = tokio::runtime::Runtime::new().unwrap();
1431        let lua = Lua::new();
1432        let backend = Arc::new(FindingsAlwaysBackend {
1433            findings: vec![default_finding()],
1434        });
1435        let scheduler = converge_scheduler(backend);
1436        let run_id = Uuid::now_v7();
1437        let _rx = scheduler.init_run(run_id, 64);
1438        let (tx, _rx2) = broadcast::channel(64);
1439        let run_ctx = RunContext {
1440            run_id,
1441            cancel: CancellationToken::new(),
1442            events: tx,
1443        };
1444        let report_sink: ReportSink = Arc::new(std::sync::Mutex::new(None));
1445        let handle = rt.handle().clone();
1446        let cx = SdkContext::new(run_ctx, scheduler, report_sink, None, handle);
1447
1448        register_converge_sdk(&lua, &cx).unwrap();
1449
1450        let globals = lua.globals();
1451        let converge: mlua::Function = globals.get("converge").unwrap();
1452
1453        let items = lua.create_table().unwrap();
1454        items.set(1, "test item").unwrap();
1455        let options = lua.create_table().unwrap();
1456        let result: mlua::Table = converge.call((items, options)).unwrap();
1457
1458        let converged: bool = result.get("converged").unwrap();
1459        assert!(converged);
1460        let rounds: u32 = result.get("rounds").unwrap();
1461        assert!(rounds > 0);
1462        let findings: mlua::Table = result.get("findings").unwrap();
1463        assert!(findings.len().unwrap() > 0);
1464    }
1465
1466    // ═══════════════════════════════════════════════════════════════
1467    // ProducerStats / VoteStats
1468    // ═══════════════════════════════════════════════════════════════
1469
1470    #[test]
1471    fn test_producer_stats() {
1472        let s = ProducerStats {
1473            items_processed: 5,
1474            agents_run: 10,
1475            findings_generated: 20,
1476        };
1477        assert_eq!(s.items_processed, 5);
1478        assert_eq!(s.agents_run, 10);
1479        assert_eq!(s.findings_generated, 20);
1480    }
1481
1482    #[test]
1483    fn test_producer_stats_default() {
1484        let s = ProducerStats::default();
1485        assert_eq!(s.items_processed, 0);
1486        assert_eq!(s.agents_run, 0);
1487        assert_eq!(s.findings_generated, 0);
1488    }
1489
1490    #[test]
1491    fn test_vote_stats() {
1492        let s = VoteStats { approval_rate: 0.5 };
1493        assert!((s.approval_rate - 0.5).abs() < f32::EPSILON);
1494    }
1495
1496    #[test]
1497    fn test_vote_stats_default() {
1498        let s = VoteStats::default();
1499        assert!((s.approval_rate - 0.0).abs() < f32::EPSILON);
1500    }
1501
1502    // ═══════════════════════════════════════════════════════════════
1503    // Custom test backends
1504    // ═══════════════════════════════════════════════════════════════
1505
1506    /// Backend that always returns empty findings with AgentStatus::Ok.
1507    /// Used for tests that don't care about scheduler output.
1508    struct NoOpBackend;
1509
1510    #[async_trait]
1511    impl AgentBackend for NoOpBackend {
1512        fn id(&self) -> &'static str {
1513            "noop"
1514        }
1515        fn capabilities(&self) -> AgentCapabilities {
1516            AgentCapabilities::default()
1517        }
1518        fn as_any(&self) -> &dyn std::any::Any {
1519            self
1520        }
1521        async fn run(
1522            &self,
1523            task: AgentTask,
1524            _ctx: RunContext,
1525        ) -> Result<AgentResult, BackendError> {
1526            Ok(AgentResult {
1527                agent_id: task.agent_id,
1528                status: AgentStatus::Ok,
1529                output: serde_json::Value::Null,
1530                findings: vec![],
1531                tokens_used: TokenUsage::default(),
1532                artifacts: vec![],
1533                logs: LogRef::default(),
1534            })
1535        }
1536    }
1537
1538    /// Backend that always returns configured findings with AgentStatus::Ok.
1539    /// Both producer and adversary phases will see findings + Ok status.
1540    struct FindingsAlwaysBackend {
1541        findings: Vec<Finding>,
1542    }
1543
1544    #[async_trait]
1545    impl AgentBackend for FindingsAlwaysBackend {
1546        fn id(&self) -> &'static str {
1547            "findings-always"
1548        }
1549        fn capabilities(&self) -> AgentCapabilities {
1550            AgentCapabilities::default()
1551        }
1552        fn as_any(&self) -> &dyn std::any::Any {
1553            self
1554        }
1555        async fn run(
1556            &self,
1557            task: AgentTask,
1558            _ctx: RunContext,
1559        ) -> Result<AgentResult, BackendError> {
1560            Ok(AgentResult {
1561                agent_id: task.agent_id,
1562                status: AgentStatus::Ok,
1563                output: serde_json::Value::Null,
1564                findings: self.findings.clone(),
1565                tokens_used: TokenUsage::default(),
1566                artifacts: vec![],
1567                logs: LogRef::default(),
1568            })
1569        }
1570    }
1571
1572    /// Backend that returns findings but adversaries always return Error,
1573    /// causing all findings to be refuted.
1574    struct FindingsRefutingBackend {
1575        findings: Vec<Finding>,
1576    }
1577
1578    #[async_trait]
1579    impl AgentBackend for FindingsRefutingBackend {
1580        fn id(&self) -> &'static str {
1581            "findings-refute"
1582        }
1583        fn capabilities(&self) -> AgentCapabilities {
1584            AgentCapabilities::default()
1585        }
1586        fn as_any(&self) -> &dyn std::any::Any {
1587            self
1588        }
1589        async fn run(
1590            &self,
1591            task: AgentTask,
1592            _ctx: RunContext,
1593        ) -> Result<AgentResult, BackendError> {
1594            Ok(AgentResult {
1595                agent_id: task.agent_id,
1596                status: AgentStatus::Error,
1597                output: serde_json::Value::Null,
1598                findings: self.findings.clone(),
1599                tokens_used: TokenUsage::default(),
1600                artifacts: vec![],
1601                logs: LogRef::default(),
1602            })
1603        }
1604    }
1605}