diff --git a/v2/crates/ruview-swarm/src/integration/swarm_sim.rs b/v2/crates/ruview-swarm/src/integration/swarm_sim.rs index 2c8e7224..9c392ff7 100644 --- a/v2/crates/ruview-swarm/src/integration/swarm_sim.rs +++ b/v2/crates/ruview-swarm/src/integration/swarm_sim.rs @@ -219,14 +219,57 @@ pub async fn run_mission_with_report( } } - // Gather detections from each drone's CSI pipeline at its current position + // Gather detections from each drone's CSI pipeline at its current position. + // Track which drone produced each detection so we can vector peers toward it. let mut step_detections: Vec = Vec::new(); + let mut detection_anchors: Vec = Vec::new(); for drone in &drones { if let Some(det) = drone.csi_pipeline.scan(&drone.state.position).await { + if let Some(vp) = det.victim_position { + detection_anchors.push(vp); + } step_detections.push(det); } } + // Phase 3 convergence assist: when a single drone has a contact but no + // second viewpoint, vector the nearest idle peer toward that contact so + // two drones can confirm it via multi-view fusion (Wi2SAR §V convergence). + if step_detections.len() == 1 { + if let Some(anchor) = detection_anchors.first().copied() { + let detector = step_detections[0].drone_id; + // Find the nearest peer that is not the detector. + let mut best: Option<(usize, f64)> = None; + for (idx, drone) in drones.iter().enumerate() { + if drone.node_id == detector { + continue; + } + let d = drone.state.position.distance_to(&anchor); + if best.map(|(_, bd)| d < bd).unwrap_or(true) { + best = Some((idx, d)); + } + } + if let Some((idx, _)) = best { + let speed = profile_config.planning.max_speed_ms.max(1.0); + let p = drones[idx].state.position; + let dx = anchor.x - p.x; + let dy = anchor.y - p.y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist > 1e-6 { + let step = speed.min(dist); + drones[idx].state.position.x += (dx / dist) * step; + drones[idx].state.position.y += (dy / dist) * step; + } + // Re-scan the vectored peer; if it now has a contact, add it. + if let Some(det) = + drones[idx].csi_pipeline.scan(&drones[idx].state.position).await + { + step_detections.push(det); + } + } + } + } + // Multi-drone fusion if step_detections.len() >= 2 { let positions: Vec<(NodeId, Position3D)> = @@ -265,7 +308,35 @@ pub async fn run_mission_with_report( } } - // Collision check + // Collision avoidance: enforce minimum separation by nudging drones apart. + // This models the formation min-separation guard so converging drones in + // Phase 3 do not physically overlap. Runs before the collision metric so a + // properly separated swarm records zero collision events. + let min_sep = profile_config.formation.min_separation_m.max(1.5); + let snapshot: Vec = drones.iter().map(|d| d.state.position).collect(); + for i in 0..drones.len() { + let mut push = (0.0_f64, 0.0_f64); + for (j, other) in snapshot.iter().enumerate() { + if i == j { + continue; + } + let dx = drones[i].state.position.x - other.x; + let dy = drones[i].state.position.y - other.y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist < min_sep && dist > 1e-6 { + let overlap = (min_sep - dist) / 2.0; + push.0 += (dx / dist) * overlap; + push.1 += (dy / dist) * overlap; + } else if dist <= 1e-6 { + // Exactly coincident: deterministic split by index. + push.0 += (i as f64 - j as f64) * min_sep * 0.5; + } + } + drones[i].state.position.x += push.0; + drones[i].state.position.y += push.1; + } + + // Collision metric: count residual pairwise breaches after separation. for i in 0..drones.len() { for j in (i + 1)..drones.len() { if drones[i].state.position.distance_to(&drones[j].state.position) < 1.5 { @@ -380,7 +451,6 @@ mod tests { Position3D { x: 250.0, y: 180.0, z: 0.0 }, ]; let report = run_mission_with_report(cfg, 4, victims, 200, 1.0).await; - eprintln!("DEBUG collision_events={}", report.collision_events); assert_eq!(report.profile, "sar"); assert_eq!(report.victims_total, 2); assert_eq!(report.collision_events, 0, "no collisions expected"); diff --git a/v2/crates/ruview-swarm/src/ruflo/http_backend.rs b/v2/crates/ruview-swarm/src/ruflo/http_backend.rs index c40d8e46..7a934518 100644 --- a/v2/crates/ruview-swarm/src/ruflo/http_backend.rs +++ b/v2/crates/ruview-swarm/src/ruflo/http_backend.rs @@ -5,8 +5,13 @@ use async_trait::async_trait; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; use super::backend::*; +/// Per-request timeout applied to every JSON-RPC call. +/// A dead or slow daemon must not stall swarm operation loops. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + pub struct HttpRufloBackend { client: reqwest::Client, base_url: String, @@ -15,8 +20,12 @@ pub struct HttpRufloBackend { impl HttpRufloBackend { pub fn new(base_url: &str) -> Self { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .expect("failed to build reqwest client"); Self { - client: reqwest::Client::new(), + client, base_url: base_url.trim_end_matches('/').to_string(), request_id: AtomicU64::new(1), }