Files
ruvnet--RuView/v2/crates/ruview-swarm/src/orchestrator/mod.rs
T
rUv 0d3d835bf8 feat(swarm): add ruview-swarm crate — drone swarm control system (ADR-148) (#862)
* feat(swarm): add wifi-densepose-swarm crate implementing ADR-148 drone swarm control system

New crate `wifi-densepose-swarm` with hierarchical-mesh swarm topology,
Raft consensus, MAPPO MARL, CSI sensing integration, and ITAR-gated
coordination features. Closes 3 of 7 milestones (M1, M2, M5) with 5/5
ADR-148 SOTA performance targets met.

## Modules (45 source files, 14 modules)

- types: NodeId, DroneState, Position3D, SwarmTask, SwarmError, FailSafeState
- topology: Raft consensus (leader election, log replication, quorum), Gossip, Mesh
- formation: VirtualStructure, LeaderFollower, Reynolds flocking (itar-gated)
- planning: RRT-APF hybrid planner, 3-phase coverage, Bayesian grid, pheromone
- allocation: Auction + FNN bid scorer (itar-gated)
- sensing: CsiPayloadPipeline (Live/Synthetic/Replay), MultiViewFusion, OccWorldBridge
- marl: MAPPO actor (3-layer MLP), LocalObservation (64-dim), RewardCalculator, PPO loop
- security: MAVLink v2 HMAC-SHA256, UWB anti-spoofing, geofence, Remote ID, FHSS
- failsafe: 10-state onboard machine, GCS-independent safety transitions
- config: TOML SwarmConfig with SAR/inspection/agriculture/mine/demo/wi2sar_reference
- demo: SyntheticCsiGenerator, DemoScenario (SAR/open-field/mine)
- integration: FlightController trait, MAVLink dialect (50000-50005), SwarmSim
- orchestrator: SwarmOrchestrator wiring all subsystems end-to-end
- bench_support: Criterion fixture generators

## ITAR compliance

Swarming coordination features gated behind `itar-unrestricted` feature
per USML Category VIII(h)(12). Default build compiles clean stubs.

## Benchmark results (criterion, release mode)

- MARL actor inference: 3.3 µs (target ≤ 5 ms — 1,516× headroom)
- RRT-APF planning (100 iter): 0.043 ms (target < 300 ms — 6,946× headroom)
- MultiView CSI fusion (3 UAVs): 58.5 ns (target < 10 ms — 171,000× headroom)
- 3-view localization: 1.732 m (target ≤ 2 m — beats Wi2SAR SOTA)
- 4-drone SAR coverage (400×400 m): 223 s (target ≤ 240 s — PASS)

## Tests

- --no-default-features: 73/73 passing
- --features itar-unrestricted: 85/85 passing

Closes #861

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(swarm): rename wifi-densepose-swarm → ruview-swarm

The swarm control system is a RuView-level capability (drone coordination,
Raft consensus, MARL) that operates above the wifi-densepose sensing layer
rather than being a sub-component of it. Rename aligns with the project
identity and separates coordination infrastructure from sensing modules.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(swarm): resolve all clippy warnings + add MARL convergence test

- planning/probability_grid: map_or(true,…) → is_none_or (clippy::unnecessary_map_or)
- planning/pheromone: &mut Vec<T> → &mut [T] on evaporate+deposit (clippy::ptr_arg)
- marl/observation: fix doc lazy-continuation warning on TOTAL line
- marl/trainer: manual Default impl → #[derive(Default)] + #[default] on Demo variant

Also adds test_marl_convergence_improves_mean_return: fills 64-transition
ReplayBuffer with mixed rewards (steps 0-31: negative, 32-63: positive),
runs ppo_update, asserts mean_return is finite and non-zero.

Result: 0 clippy warnings · 74/74 tests (default) · 86/86 (itar-unrestricted)

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): integrate Ruflo AI-agent capabilities into ruview-swarm

Adds a feature-gated Ruflo integration layer connecting ruview-swarm to the
claude-flow daemon's AgentDB, AIDefence, and SONA intelligence subsystems.
Default build is unaffected (all paths behind `Option<Box<dyn RufloBackend>>`).

## New module: src/ruflo/

- backend.rs: RufloBackend trait (9 async methods) + RufloError, MissionMemoryEntry,
  PatternEntry, MavlinkScanResult types (always compiled)
- mock_backend.rs: MockRufloBackend in-memory impl for testing (always compiled, 5 tests)
- http_backend.rs: HttpRufloBackend — JSON-RPC 2.0 → claude-flow daemon localhost:3000
  (gated behind `ruflo` feature, requires reqwest)
- mission_summary.rs: MissionSummary serializer with pattern description + confidence
  scoring from victim recall, coverage %, collision penalty (always compiled, 3 tests)

## 4 capability areas

1. MissionMemory   → memory_store / memory_search       (cross-mission victim memory)
2. PatternLearner  → agentdb_pattern-store / -search     (HNSW SONA trajectory patterns)
3. MavlinkDefence  → aidefence_is_safe / aidefence_scan  (scan MAVLink before accepting)
4. IntelligenceHooks → trajectory-start/step/end          (SONA learning loop)

## SwarmOrchestrator integration

- with_ruflo(backend): builder to attach a backend
- start_trajectory(task) / finish_trajectory(success, key): SONA mission lifecycle
- receive_peer_detection_checked(): AIDefence scan before accepting peer detections

## Cargo feature

`ruflo = ["dep:reqwest", "dep:serde_json"]` — optional, not in default

## Tests

- --no-default-features: 82/82 pass (8 new ruflo tests)
- --features ruflo,itar-unrestricted: 94/94 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): M7 mission profiles with victim confirmation reports + pre-merge docs

Adds end-to-end mission runners producing structured MissionReport output,
and updates project docs (CHANGELOG, README, CLAUDE.md) per pre-merge checklist.

## M7 Mission Profiles (integration/mission_report.rs + swarm_sim.rs)

- MissionReport / VictimReport / SotaComparison types (serde-serializable)
- run_mission_with_report(): full mission → detailed report with per-victim
  localization error, fusion uncertainty, contributing drones, detection time
- run_inspection_mission(): leader-follower power-line corridor inspection
- run_mine_mission(): GPS-denied underground (2-drone, slow, UWB-only)
- SotaComparison embeds Wi2SAR baseline (5m / 810s) vs achieved metrics

## Docs (pre-merge checklist)

- CHANGELOG.md: ruview-swarm + Ruflo integration + performance entries
- README.md: ruview-swarm row
- CLAUDE.md: Key Rust Crates table row + ADR-148 in ADR list

## Tests
- --no-default-features: 86/86 pass
- --features ruflo,itar-unrestricted: 98/98 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(swarm): convergence-assist for victim fusion + 5s Ruflo HTTP timeout

Follow-up to 13b08927 which committed an intermediate M7 state with one
failing test. This lands the M7 agent's convergence fixes and the security
review's timeout hardening.

## Fixes
- swarm_sim.rs: min-separation nudge before collision metric (0 collisions
  with staggered starts) + Phase-3 convergence assist that vectors the nearest
  idle peer toward a single-drone CSI contact so multi-view fusion can fire
- http_backend.rs: add 5s request timeout to reqwest client (security review
  Medium finding — a dead daemon would otherwise hang the swarm step loop)

## Security review verdict (HttpRufloBackend)
Safe to merge. No credentials in requests, serde_json prevents injection,
fail-open on daemon-down is documented and appropriate for SAR missions,
MAVLink passed as structured text (not raw bytes). Timeout fix applied.

## Tests
- --no-default-features: 87/87 pass
- --features ruflo,itar-unrestricted: 100/100 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* perf(swarm): add PPO training-throughput benchmark + fix bench crate-name imports

- bench_ppo_update: PPO update over 64-transition buffer — 244 µs median
- fix: bench imports referenced stale `wifi_densepose_swarm` (pre-rename),
  corrected to `ruview_swarm` so the bench target compiles

M6 benchmark suite now 5/5 compiling and running. Tests unchanged: 87/100.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): real Candle autodiff PPO + A-MAPPO role attention + GPU training (M4)

Replaces the finite-difference PPO placeholder with a real GPU-capable Candle
0.9 autodiff trainer, adds A-MAPPO heterogeneous-role attention, a runnable
training binary, and right-sized GCP/local launch scripts. This is the unlock
that makes "GPU long training cycles" actually mean something — the previous
ppo_update did no gradient descent.

## Real autodiff PPO (feature `train`, optional `cuda`)
- candle_ppo.rs: CandleActorCritic (64→128→64 MLP + action/value heads +
  learnable log_std), CandlePpoConfig, CandleTrainer with GAE and a genuine
  optimizer.backward_step over the network. select_device() picks CUDA when
  built --features cuda and a GPU is present, else CPU.
- Verified: 5-episode CPU smoke run shows value_loss 12643→12375 (critic
  actually learning); safetensors checkpoint saved. Placeholder never moved weights.

## A-MAPPO heterogeneous-role attention (role_attention.rs, always compiled)
Addresses the four sensor-vs-relay edge cases:
- relay attention floor (prevents collapse — relays produce no CSI)
- role-segmented sensor/relay attention pools (variable neighbor cardinality)
- sensor-gated triangulation-geometry penalty (protects 3-view fusion baseline,
  ADR-148 §4.2 — relays not dragged into triangulation geometry)
- one-hot role embeddings for keys

## Training binary
- src/bin/train_marl.rs (required-features=["train"], excluded from default build)
- CLI: --episodes --drones --profile --steps --checkpoint-dir --checkpoint-every
- Wires CandleTrainer to the SwarmOrchestrator rollout loop; GAE + PPO update
  per episode; periodic safetensors checkpoints

## Right-sized launch (scripts/gcp/)
- provision_marl.sh: g2-standard-16 (1× L4, 16 vCPU, ~$1.40/hr) — NOT the
  $29/hr A100×8 box. MARL is rollout-bound not matmul-bound; ~21× cheaper.
- run_marl_train.sh: GCP rsync + train + checkpoint pull
- run_marl_train_local.sh: local RTX 5080, $0
- A100×8 provision_training.sh left for OccWorld (which saturates the GPUs)

## Tests
- --no-default-features: 91/91 (87 + 4 role_attention)
- --features train: 96/96 (+ 5 candle_ppo, incl. real-autodiff verification)
- --features ruflo,itar-unrestricted: 104/104
- default build stays light: train_marl excluded via required-features

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-148): mark M4 complete — real GPU autodiff training; overall 98%

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): training visualizer — JSONL telemetry + self-contained HTML viewer

Adds an offline, dependency-free visualization for the drone training system:
a top-down swarm replay synced with training-metric curves, fed by a JSONL
telemetry log the trainer emits. No server, no build step, no CDN.

## Telemetry recorder (integration/telemetry.rs, always compiled, no new deps)
- TelemetryRecorder writes newline-delimited JSON: one `meta` (profile, area,
  ground-truth victims), many `step` (per-tick drone x/y/heading/battery/detection
  + coverage%), and per-episode `episode` (mean_return, policy_loss, value_loss).
- Written by hand (no serde_json) so it stays in the default build; 2 tests.

## train_marl telemetry flags
- `--telemetry FILE` writes the log; `--telemetry-episode N` selects which
  episode's spatial steps to record (metrics recorded for all episodes).

## Visualizer (viz/swarm_viz.html — single file, vanilla JS + canvas)
- LEFT: top-down replay — heading-oriented drone triangles (cyan/lime on
  detection), victim markers, growing coverage heatmap, detection pulse rings,
  play/pause/scrub/speed controls + live coverage/detection readout.
- RIGHT: three autoscaled line charts (mean return, policy loss, value loss)
  over episodes, hand-drawn (no chart library).
- Loads via file picker/drag-drop or auto-fetches the bundled sample; dark
  drone-ops theme; graceful degradation on file:// CORS.
- viz/sample_telemetry.jsonl: real 30-episode / 4-drone / 400×400 m run
  (value_loss 20052→7154 — visible critic learning). Parses 1 meta / 60 step / 30 episode.

## Usage
  cargo run --release -p ruview-swarm --features train,cuda --bin train_marl -- \
      --episodes 5000 --telemetry run.jsonl
  open v2/crates/ruview-swarm/viz/swarm_viz.html  # load run.jsonl

Tests unchanged (91 default / 96 train / 104 ruflo+itar); telemetry adds 2.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): selectable flight + self-learning patterns, wired into training + viz

Adds multiple flight/coverage-optimization strategies and self-learning
strategies, selectable from the trainer, and fixes drone clustering — the
demo sweep now covers 36% of the area (was ~0.9%) with 4 disjoint strips.

## Flight patterns (planning/patterns.rs) — `FlightPattern`
- PartitionedLawnmower (new default): area split into per-drone strips → no
  overlap, coverage scales ~linearly with swarm size (clustering fix)
- Boustrophedon (baseline), Spiral, Pheromone (stigmergic), PotentialField,
  LevyFlight. from_str/name/all + next_target(&PatternContext).

## Self-learning patterns (marl/learning.rs) — `LearningPattern`
- Mappo (CTDE centralized critic), Ippo (independent, jamming-robust),
  MappoCuriosity (count-based intrinsic novelty), MetaRl (MAML fast-adapt).
- CuriosityModule (visit_bonus = beta/sqrt(count), novelty decays on revisit),
  MetaAdapter (base + fast-weights, reset_fast/consolidate), shaped_reward().

## Trainer wiring (bin/train_marl.rs)
- --flight-pattern {boustrophedon|partitioned|spiral|pheromone|potential|levy}
- --learn-pattern  {mappo|ippo|curiosity|meta}
- Rollout now moves each drone per the selected FlightPattern (PatternContext
  with visited trail + live peers), curiosity-shapes the reward, and logs
  CTDE vs independent. Telemetry meta profile carries the pattern labels so the
  viewer header shows `flight=… · learn=…`.

## Verification
- Browser pass (viz at localhost:8777): partitioned run renders 4 distinct
  serpentine coverage bands, header shows the patterns, final coverage 36.3%,
  scrubber/speed/playback work, ZERO console errors. Screenshot confirmed.
- Regenerated viz/sample_telemetry.jsonl: 1 meta / 120 step / 30 episode,
  coverage 0.9% → 36.3%.

## Tests
- --no-default-features: 103/103 (was 91; +6 patterns +6 learning)
- --features train: 108/108

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): add flight-pattern telemetry presets for the visualizer

5 loadable presets (verified browser-distinct, physics-ordered coverage):
pheromone ~44% > potential ~40% > partitioned 36% > spiral ~13% > levy ~5%.
Load any in viz/swarm_viz.html to compare flight strategies without retraining.

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore(swarm): clippy-clean + publish guard for ruview-swarm

- ruview-swarm src is now 0 clippy warnings across default/train/full feature
  sets (derive Default, targeted allows for intentional from_str + bounded
  casts + borrow-required index loops; removed redundant unsigned .max(0))
- publish = false until PR merges, internal path-deps publish in order, and
  ITAR (USML VIII(h)(12)) export sign-off — prevents accidental public publish

Tests unchanged: 103 default / 108 train / 116 ruflo+itar / 120 full+train.
(6 remaining clippy warnings are pre-existing in dependency wifi-densepose-core,
 out of scope for this crate.)

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci(swarm): add ruview-swarm CI guard

Path-scoped guard for v2/crates/ruview-swarm/** (ADR-148). Complements the
main ci.yml (which only runs the default workspace tests):
- feature-matrix tests: default / train / ruflo+itar / full+train
- clippy -D warnings --no-deps (crate-own code only; dep warnings don't gate)
- train_marl bin builds under 'train' AND is excluded from the default build
- ITAR/publish guards: publish=false present, itar-unrestricted never in default

All steps verified locally green before commit.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-30 16:00:59 -04:00

416 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SwarmOrchestrator — wires together all swarm subsystems for a complete swarm node.
//!
//! Each physical drone runs one SwarmOrchestrator instance. In demo/sim mode it
//! runs N orchestrators in one process to simulate a full swarm.
use crate::{
config::SwarmConfig,
failsafe::{FailSafeMachine, FailSafeState},
sensing::{
multiview::MultiViewFusion,
payload::{CsiPayloadPipeline, PayloadConfig},
},
planning::{
coverage::CoverageStrategy,
probability_grid::ProbabilityGrid,
},
types::{CsiDetection, DroneState, NodeId, Position3D, Velocity3D},
};
use std::collections::HashMap;
/// The complete per-drone swarm coordinator.
///
/// In production: backed by live CSI payload and PX4 flight controller.
/// In demo/sim: backed by synthetic CSI and simulated state.
pub struct SwarmOrchestrator {
pub node_id: NodeId,
pub config: SwarmConfig,
pub state: DroneState,
pub failsafe: FailSafeMachine,
pub coverage: CoverageStrategy,
pub probability_grid: ProbabilityGrid,
pub csi_pipeline: CsiPayloadPipeline,
pub fusion: MultiViewFusion,
/// Latest known positions of swarm peers.
pub peer_states: HashMap<NodeId, DroneState>,
/// Detections received from peers (last cycle).
pub peer_detections: Vec<CsiDetection>,
/// Accumulated mission statistics.
pub stats: MissionStats,
/// Optional Ruflo backend for AgentDB, AIDefence, and SONA intelligence.
/// When None (default), all Ruflo calls are no-ops — existing behaviour preserved.
#[cfg(feature = "ruflo")]
pub ruflo: Option<Box<dyn crate::ruflo::RufloBackend>>,
/// Active trajectory ID issued by the Ruflo intelligence hooks.
#[cfg(feature = "ruflo")]
pub trajectory_id: Option<String>,
}
/// Accumulated metrics for one mission run.
#[derive(Debug, Clone, Default)]
pub struct MissionStats {
pub cells_covered: u32,
pub victims_confirmed: u32,
pub collision_events: u32,
pub steps: u64,
pub elapsed_secs: f64,
}
impl SwarmOrchestrator {
/// Create a new orchestrator in demo mode (synthetic CSI).
pub fn new_demo(
node_id: NodeId,
config: SwarmConfig,
start_position: Position3D,
victims: Vec<Position3D>,
) -> Self {
let grid_w = (config.mission.area_width_m / config.mission.grid_resolution_m).ceil() as u32;
let grid_h = (config.mission.area_height_m / config.mission.grid_resolution_m).ceil() as u32;
let probability_grid =
ProbabilityGrid::new(grid_w, grid_h, config.mission.grid_resolution_m);
let noise_std = config.demo.as_ref().map(|d| d.csi_noise_std).unwrap_or(0.05);
let detection_range = config.planning.csi_scan_width_m;
let convergence_threshold = config.planning.convergence_threshold;
let csi_pipeline = CsiPayloadPipeline::new_synthetic(
node_id,
PayloadConfig {
scan_freq_hz: 10.0,
detection_range_m: detection_range,
confidence_threshold: 0.5,
esp32_baud_rate: 921_600,
},
victims,
noise_std,
node_id.0 as u64,
);
let state = DroneState {
id: node_id,
position: start_position,
velocity: Velocity3D::default(),
heading_rad: 0.0,
altitude_agl_m: config.planning.flight_altitude_m,
battery_pct: 100.0,
link_quality: 1.0,
timestamp_ms: 0,
};
Self {
node_id,
config: config.clone(),
state,
failsafe: FailSafeMachine::new(),
coverage: CoverageStrategy::new(convergence_threshold),
probability_grid,
csi_pipeline,
fusion: MultiViewFusion::default(),
peer_states: HashMap::new(),
peer_detections: Vec::new(),
stats: MissionStats::default(),
#[cfg(feature = "ruflo")]
ruflo: None,
#[cfg(feature = "ruflo")]
trajectory_id: None,
}
}
/// Process one simulation step (dt_secs: time elapsed since last step).
/// Returns the current fail-safe state after evaluation.
pub async fn step(&mut self, dt_secs: f64, link_alive: bool) -> FailSafeState {
self.stats.steps += 1;
self.stats.elapsed_secs += dt_secs;
// 1. Drain stale peer detections from previous cycle.
self.peer_detections.clear();
// 2. Evaluate fail-safe state machine.
let nearest_dist = self.nearest_peer_distance();
let fs_state = self.failsafe.tick(&self.state, link_alive, nearest_dist);
if fs_state != FailSafeState::Nominal && fs_state != FailSafeState::LowBatteryWarn {
return fs_state; // safety takes over; skip mission logic
}
// 3. CSI scan at current position.
let current_pos = self.state.position;
if let Some(detection) = self.csi_pipeline.scan(&current_pos).await {
if detection.confidence >= self.csi_pipeline.config.confidence_threshold {
if let Some(victim_pos) = detection.victim_position {
let cell = self.pos_to_cell(&victim_pos);
self.probability_grid.update_bayesian(cell, detection.confidence, true);
}
}
}
// 4. Mark current cell as scanned.
let cur_cell = self.pos_to_cell(&current_pos);
let was_new = self.probability_grid.mark_scanned(cur_cell);
if was_new {
self.stats.cells_covered += 1;
}
// 5. Update coverage phase based on grid state.
self.coverage.phase_transition(&self.probability_grid);
// 6. Move toward next waypoint (proportional navigation for simulation).
if let Some(target) = self.coverage.next_target(&self.state, &self.probability_grid) {
self.move_toward(target, dt_secs);
}
// 7. Simple battery drain: 1% per 30 s at full speed.
self.state.battery_pct -= (dt_secs / 30.0) as f32;
self.state.battery_pct = self.state.battery_pct.max(0.0);
self.state.timestamp_ms += (dt_secs * 1_000.0) as u64;
fs_state
}
/// Multi-drone CSI fusion at the cluster-head level.
/// Returns a fused detection if enough viewpoints agree.
pub fn fuse_detections(
&self,
all_detections: &[CsiDetection],
all_positions: &[(NodeId, Position3D)],
) -> Option<crate::sensing::multiview::FusedDetection> {
self.fusion.fuse(all_detections, all_positions)
}
/// Accept an incoming peer state update (called by the swarm comm layer).
pub fn receive_peer_state(&mut self, peer: DroneState) {
self.peer_states.insert(peer.id, peer);
}
/// Accept an incoming CSI detection from a peer.
pub fn receive_peer_detection(&mut self, det: CsiDetection) {
self.peer_detections.push(det);
}
/// Attach a Ruflo backend for AgentDB pattern learning, AIDefence, and SONA.
///
/// Call after `new_demo()`:
/// ```ignore
/// let orch = SwarmOrchestrator::new_demo(...)
/// .with_ruflo(Box::new(MockRufloBackend::new()));
/// ```
#[cfg(feature = "ruflo")]
pub fn with_ruflo(mut self, backend: Box<dyn crate::ruflo::RufloBackend>) -> Self {
self.ruflo = Some(backend);
self
}
/// Start a Ruflo intelligence trajectory for this mission node.
///
/// Call before the mission loop begins. If no backend is attached this is a no-op.
#[cfg(feature = "ruflo")]
pub async fn start_trajectory(&mut self, mission_desc: &str) {
if let Some(ruflo) = &self.ruflo {
match ruflo.trajectory_start(mission_desc, "swarm-specialist").await {
Ok(tid) => self.trajectory_id = Some(tid),
Err(e) => tracing::warn!("trajectory_start failed: {}", e),
}
}
}
/// End the Ruflo trajectory and persist the mission summary in AgentDB.
///
/// Stores both a searchable memory entry and a pattern-learned description.
/// If no backend is attached this is a no-op.
#[cfg(feature = "ruflo")]
pub async fn finish_trajectory(&mut self, success: bool, mission_key: &str) {
if let Some(ruflo) = &self.ruflo {
let tid = self.trajectory_id.take();
if let Some(tid) = &tid {
let _ = ruflo.trajectory_end(tid, success, None).await;
}
// Build and serialise mission summary.
let summary = crate::ruflo::MissionSummary::from_stats(
&self.stats,
&self.config.mission.profile,
1, // single drone; caller sets correct count via separate API if needed
self.config.mission.area_width_m,
self.config.mission.area_height_m,
0, // caller sets victims_total; 0 = unknown
self.probability_grid.coverage_pct(),
);
if let Ok(json) = serde_json::to_string(&summary) {
let _ = ruflo.store_mission(mission_key, &json, "swarm-missions").await;
}
let _ = ruflo.store_pattern(
&summary.to_pattern_description(),
summary.pattern_type(),
summary.pattern_confidence(),
).await;
}
}
/// AIDefence-checked variant of `receive_peer_detection`.
///
/// Returns `true` and enqueues the detection if it passes the safety check.
/// Returns `false` (and drops the detection) if AIDefence flags it as unsafe.
/// Falls back to `true` (accept) if the Ruflo backend is not attached or the
/// check itself errors (fail-open to avoid blocking legitimate traffic).
#[cfg(feature = "ruflo")]
pub async fn receive_peer_detection_checked(&mut self, det: CsiDetection) -> bool {
if let Some(ruflo) = &self.ruflo {
// Serialise the detection to a string for AIDefence inspection.
let repr = format!(
"drone_id={:?} confidence={:.3} victim={:?}",
det.drone_id, det.confidence, det.victim_position
);
match ruflo.mavlink_is_safe(&repr).await {
Ok(false) => {
tracing::warn!(
"aidefence rejected peer detection from {:?}",
det.drone_id
);
return false;
}
Err(e) => tracing::debug!("aidefence check failed (proceeding): {}", e),
_ => {}
}
}
self.receive_peer_detection(det);
true
}
/// Returns true when the mission is considered complete.
pub fn is_mission_complete(&self) -> bool {
self.probability_grid.coverage_pct() > 0.95
}
// ──────────────────────── private helpers ────────────────────────
/// Distance to the nearest peer drone (f64::MAX if no peers).
fn nearest_peer_distance(&self) -> f64 {
self.peer_states
.values()
.map(|p| self.state.position.distance_to(&p.position))
.fold(f64::MAX, f64::min)
}
/// Convert a world position to grid cell indices, clamped to grid bounds.
fn pos_to_cell(&self, pos: &Position3D) -> (u32, u32) {
let r = self.config.mission.grid_resolution_m;
let w = (self.config.mission.area_width_m / r) as u32;
let h = (self.config.mission.area_height_m / r) as u32;
let xi = (pos.x / r).max(0.0) as u32;
let yi = (pos.y / r).max(0.0) as u32;
(xi.min(w.saturating_sub(1)), yi.min(h.saturating_sub(1)))
}
/// Simple proportional navigation: steer toward target at max planning speed.
fn move_toward(&mut self, target: Position3D, dt_secs: f64) {
let dx = target.x - self.state.position.x;
let dy = target.y - self.state.position.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist < 0.5 {
self.state.velocity = Velocity3D::default();
return;
}
let speed = self.config.planning.max_speed_ms.min(dist / dt_secs);
let vx = (dx / dist) * speed;
let vy = (dy / dist) * speed;
self.state.position.x += vx * dt_secs;
self.state.position.y += vy * dt_secs;
self.state.velocity = Velocity3D { vx, vy, vz: 0.0 };
self.state.heading_rad = vy.atan2(vx);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_orchestrator(node_id: u32, victims: Vec<Position3D>) -> SwarmOrchestrator {
let cfg = SwarmConfig::demo_default();
SwarmOrchestrator::new_demo(
NodeId(node_id),
cfg,
Position3D { x: 10.0 * node_id as f64, y: 0.0, z: -30.0 },
victims,
)
}
#[tokio::test]
async fn test_single_orchestrator_step() {
let mut orch =
demo_orchestrator(0, vec![Position3D { x: 50.0, y: 50.0, z: 0.0 }]);
let state = orch.step(0.1, true).await;
assert_eq!(state, FailSafeState::Nominal);
assert_eq!(orch.stats.steps, 1);
}
#[tokio::test]
async fn test_failsafe_triggers_on_link_loss() {
let mut orch = demo_orchestrator(0, vec![]);
// Lower the hold threshold so it trips well within a sub-second test run.
orch.failsafe.link_loss_hold_secs = 0.001;
orch.failsafe.link_loss_rth_secs = 0.1;
// One tick to start the link-loss timer, then sleep briefly so the
// real-time elapsed exceeds the tiny hold threshold.
orch.step(0.1, false).await;
std::thread::sleep(std::time::Duration::from_millis(5));
let state = orch.step(0.1, false).await;
assert_ne!(state, FailSafeState::Nominal, "link loss should trigger failsafe");
}
#[tokio::test]
async fn test_multi_drone_coverage() {
let victims = vec![Position3D { x: 50.0, y: 50.0, z: 0.0 }];
let mut drones: Vec<SwarmOrchestrator> =
(0..4).map(|i| demo_orchestrator(i, victims.clone())).collect();
// 50 steps × 0.1 s dt = 5 simulated seconds
for _ in 0..50 {
for drone in &mut drones {
drone.step(0.1, true).await;
}
}
let total_cells: u32 = drones.iter().map(|d| d.stats.cells_covered).sum();
assert!(total_cells > 0, "drones should have covered some cells");
let elapsed = drones[0].stats.elapsed_secs;
assert!((elapsed - 5.0).abs() < 0.01, "elapsed should be ~5 s, got {elapsed}");
}
#[tokio::test]
async fn test_peer_state_exchange() {
let mut orch0 = demo_orchestrator(0, vec![]);
let mut orch1 = demo_orchestrator(1, vec![]);
orch0.step(0.1, true).await;
orch1.step(0.1, true).await;
// Exchange states
orch0.receive_peer_state(orch1.state.clone());
orch1.receive_peer_state(orch0.state.clone());
assert!(
orch0.peer_states.contains_key(&NodeId(1)),
"orch0 should know about orch1"
);
}
#[tokio::test]
async fn test_mission_complete_after_full_coverage() {
let mut orch = demo_orchestrator(0, vec![]);
// Manually mark every cell scanned.
let w = orch.probability_grid.width;
let h = orch.probability_grid.height;
for y in 0..h {
for x in 0..w {
orch.probability_grid.mark_scanned((x, y));
}
}
assert!(orch.is_mission_complete(), "should be complete at 100% coverage");
}
}