1#![allow(dead_code)]
2use 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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ConvergeConfig {
28 pub adversarial: bool,
30 pub vote_threshold: f32,
32 pub max_rounds: u32,
34 pub producers_per_item: u32,
36 pub adversaries_per_finding: u32,
38 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#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ConvergeResult {
58 pub surviving_items: Vec<serde_json::Value>,
60 pub findings: Vec<Finding>,
62 pub rounds: u32,
64 pub converged: bool,
66 pub round_stats: Vec<RoundStats>,
68}
69
70#[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
81struct ConvergeState {
83 items: Vec<serde_json::Value>,
84 findings: Vec<Finding>,
85 round_stats: Vec<RoundStats>,
86 converged: bool,
87}
88
89pub 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 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 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 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 if surviving_findings.is_empty() {
196 tracing::info!(round, "converge: all findings refuted, converged");
197 state.converged = true;
198 break;
199 }
200
201 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 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
241async 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 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, 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 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
297async 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)] struct 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
395fn extract_string(s: mlua::String) -> Option<String> {
401 s.to_str().ok().map(|s| s.to_string())
402}
403
404pub 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 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 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 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
553fn 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
581fn 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 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 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
638fn 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 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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 assert_eq!(result.rounds, 2);
1280 assert!(result.converged);
1281 assert!(!result.findings.is_empty());
1282 assert_eq!(result.round_stats.len(), 2);
1283 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 #[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 assert_eq!(result.rounds, 2);
1320 assert!(result.converged);
1321 assert!(!result.findings.is_empty());
1322 }
1323
1324 #[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 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 #[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 assert!(!result.converged);
1386 assert_eq!(result.findings.len(), 1);
1387 }
1388
1389 #[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 #[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 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 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 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}