Skip to main content

ados_swarm/cbba/
mod.rs

1//! CBBA task allocation across the fleet.
2//!
3//! Consensus-Based Bundle Algorithm: each drone greedily builds a bundle of tasks
4//! it would like (phase 1), broadcasts its bid vector, and resolves conflicts
5//! against every peer's vector with a fixed decision table (phase 2,
6//! [`consensus`]). No coordinator, no leader, no central assignment.
7//!
8//! # Why one round is enough here
9//!
10//! The literature bounds convergence at `N_min · D` iterations, where `D` is the
11//! diameter of the interaction graph - a mesh of point-to-point links needs
12//! `D` hops for one agent's bid to reach the far side. A shared broadcast medium
13//! has `D = 1`: every drone hears every drone's bid directly, so the diameter
14//! factor vanishes and the bound collapses to `N_min`. When the greedy bundles
15//! are already conflict-free, that is literally ONE round - one build, one
16//! broadcast, done, which [`converge_broadcast`] measures rather than assumes.
17//!
18//! # Rate
19//!
20//! Bids are EVENT-DRIVEN: a task-set change or a reallocation, never a periodic
21//! tick. This module deliberately exposes no timer.
22
23pub mod bid;
24pub mod consensus;
25
26use std::collections::BTreeMap;
27
28pub use bid::{BidVector, BID_BYTES_PER_AGENT, BID_BYTES_PER_TASK};
29pub use consensus::TableAction;
30
31use crate::geo::Ned;
32
33/// Per-second reward discount. The score is `Σ λ^{t_j} r_j` over the path's
34/// arrival times, which is what gives CBBA its diminishing-marginal-gain
35/// property - the property the convergence proof rests on. A plain
36/// `reward − distance` score does NOT satisfy it and can livelock.
37pub const CBBA_DISCOUNT: f64 = 0.95;
38
39/// Assumed transit speed for scoring, m/s. Only ratios matter, so this need not
40/// match the vehicle exactly; it must be positive and the same on every drone.
41pub const CBBA_SPEED_MPS: f64 = 8.0;
42
43/// Default tasks per drone.
44pub const CBBA_BUNDLE_CAPACITY: usize = 5;
45
46/// A task to be allocated: a waypoint or survey cell.
47#[derive(Debug, Clone, PartialEq)]
48pub struct CbbaTask {
49    /// Stable task identifier.
50    pub id: String,
51    /// Position in the fleet's shared local NED frame.
52    pub pos: Ned,
53    /// Reward for completing the task, before the time discount.
54    pub reward: f32,
55}
56
57impl CbbaTask {
58    /// A task at `pos` worth `reward`.
59    pub fn new(id: impl Into<String>, pos: Ned, reward: f32) -> Self {
60        Self {
61            id: id.into(),
62            pos,
63            reward,
64        }
65    }
66}
67
68/// What this drone is currently assigned - the read-only pair the settings page
69/// surfaces (`swarm.tasks.assigned_task_id`, `swarm.tasks.bundle_position`).
70/// Never the bid internals: the operator wants the assignment, not the auction.
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
72pub struct TaskAssignment {
73    /// The task this agent won, if any.
74    pub task_id: Option<String>,
75    /// Position of that task in this agent's bundle.
76    pub bundle_position: Option<usize>,
77}
78
79/// One drone's auction state.
80#[derive(Debug, Clone)]
81pub struct CbbaAgent {
82    /// This agent's fleet slot.
83    pub slot: u8,
84    /// Where this drone starts its path, in the shared local frame.
85    pub pos: Ned,
86    /// Cruise speed used to turn path length into time, metres/second.
87    pub speed: f64,
88    /// Maximum tasks in one bundle.
89    pub capacity: usize,
90    /// Per-second time discount applied to rewards (0..1).
91    pub discount: f64,
92    bundle: Vec<usize>,
93    path: Vec<usize>,
94    vector: BidVector,
95    scratch: Vec<usize>,
96}
97
98impl CbbaAgent {
99    /// An agent at `pos`, sized for `n_tasks` tasks and `n_agents` agents.
100    pub fn new(slot: u8, pos: Ned, n_tasks: usize, n_agents: usize) -> Self {
101        Self {
102            slot,
103            pos,
104            speed: CBBA_SPEED_MPS,
105            capacity: CBBA_BUNDLE_CAPACITY,
106            discount: CBBA_DISCOUNT,
107            bundle: Vec::new(),
108            path: Vec::new(),
109            vector: BidVector::new(n_tasks, n_agents),
110            scratch: Vec::new(),
111        }
112    }
113
114    /// Set the bundle capacity.
115    pub fn with_capacity(mut self, capacity: usize) -> Self {
116        self.capacity = capacity.max(1);
117        self
118    }
119
120    /// This agent's current bid vector, as broadcast to peers.
121    pub fn vector(&self) -> &BidVector {
122        &self.vector
123    }
124
125    /// Tasks in execution order.
126    pub fn path(&self) -> &[usize] {
127        &self.path
128    }
129
130    /// Tasks in the order they were added - the order releases walk.
131    pub fn bundle(&self) -> &[usize] {
132        &self.bundle
133    }
134
135    /// The next task and its position in the bundle.
136    pub fn assignment(&self, tasks: &[CbbaTask]) -> TaskAssignment {
137        let Some(&next) = self.path.first() else {
138            return TaskAssignment::default();
139        };
140        TaskAssignment {
141            task_id: tasks.get(next).map(|t| t.id.clone()),
142            bundle_position: self.bundle.iter().position(|&j| j == next),
143        }
144    }
145
146    /// Bundle-building step: greedily grow the bundle while a task's marginal score beats the
147    /// standing winning bid. Returns whether anything was added.
148    pub fn build_bundle(&mut self, tasks: &[CbbaTask]) -> bool {
149        let mut changed = false;
150        while self.bundle.len() < self.capacity && self.path.len() < tasks.len() {
151            let base = path_score(self.pos, self.speed, self.discount, tasks, &self.path);
152            let mut best: Option<(usize, usize, f32)> = None;
153            for j in 0..tasks.len() {
154                if self.path.contains(&j) {
155                    continue;
156                }
157                let mut here: Option<(usize, f64)> = None;
158                for at in 0..=self.path.len() {
159                    self.scratch.clear();
160                    self.scratch.extend_from_slice(&self.path);
161                    self.scratch.insert(at, j);
162                    let marginal =
163                        path_score(self.pos, self.speed, self.discount, tasks, &self.scratch)
164                            - base;
165                    if here.is_none_or(|(_, m)| marginal > m) {
166                        here = Some((at, marginal));
167                    }
168                }
169                let Some((at, marginal)) = here else { continue };
170                // Compare at WIRE precision. The score is computed in f64 but the
171                // bid vector carries f32, and `f32(x) < x` for most x - so an f64
172                // comparison against a widened f32 makes a drone find its OWN
173                // losing bid beatable, re-bid the identical value, get outbid
174                // again, and oscillate until the round bound. Rounding first makes
175                // "beats the standing bid" exact and terminating.
176                let marginal = marginal as f32;
177                // Strictly beat the standing bid, and prefer the lowest task index
178                // on a tie so every drone's greedy step is reproducible.
179                if marginal > self.vector.y[j] && best.is_none_or(|(_, _, m)| marginal > m) {
180                    best = Some((j, at, marginal));
181                }
182            }
183            let Some((j, at, marginal)) = best else { break };
184            self.path.insert(at, j);
185            self.bundle.push(j);
186            self.vector.y[j] = marginal;
187            self.vector.z[j] = Some(self.slot);
188            changed = true;
189        }
190        changed
191    }
192
193    /// Consensus step: integrate a peer's bid vector. Returns whether the ASSIGNMENT
194    /// changed; the information timestamps are merged either way but do not count,
195    /// since a monotonic clock would otherwise make the auction never settle.
196    pub fn receive(&mut self, sender: u8, theirs: &BidVector, roster: &[u8], now: u16) -> bool {
197        if theirs.y.len() != self.vector.y.len() || theirs.s.len() != self.vector.s.len() {
198            return false;
199        }
200        let mut changed = false;
201        for j in 0..self.vector.y.len() {
202            let action = consensus::decide(j, sender, self.slot, theirs, &self.vector, roster);
203            changed |= consensus::apply(action, j, theirs, &mut self.vector);
204        }
205        consensus::merge_stamps(&mut self.vector, theirs, roster, sender, now);
206        changed
207    }
208
209    /// Drop the first task this drone no longer holds and everything added after
210    /// it. Those later bids were priced against a path that no longer exists, so
211    /// keeping them would leave the fleet holding stale prices.
212    pub fn release_lost(&mut self) -> bool {
213        let Some(from) = self
214            .bundle
215            .iter()
216            .position(|&j| self.vector.z[j] != Some(self.slot))
217        else {
218            return false;
219        };
220        for n in (from..self.bundle.len()).rev() {
221            let j = self.bundle[n];
222            if n > from {
223                self.vector.y[j] = 0.0;
224                self.vector.z[j] = None;
225            }
226            if let Some(at) = self.path.iter().position(|&t| t == j) {
227                self.path.remove(at);
228            }
229            self.bundle.pop();
230        }
231        true
232    }
233}
234
235/// Time-discounted path reward: `Σ λ^{arrival time} · reward`.
236fn path_score(start: Ned, speed: f64, discount: f64, tasks: &[CbbaTask], path: &[usize]) -> f64 {
237    let speed = if speed.is_finite() && speed > 0.0 {
238        speed
239    } else {
240        CBBA_SPEED_MPS
241    };
242    let mut at = start;
243    let mut t = 0.0;
244    let mut score = 0.0;
245    for &j in path {
246        let Some(task) = tasks.get(j) else { continue };
247        t += (task.pos - at).norm() / speed;
248        score += discount.powf(t) * task.reward as f64;
249        at = task.pos;
250    }
251    score
252}
253
254/// What [`converge_broadcast`] measured.
255#[derive(Debug, Clone, PartialEq)]
256pub struct ConvergenceReport {
257    /// Communication rounds in which the assignment changed. This is the figure
258    /// the diameter-1 claim is about.
259    pub rounds: usize,
260    /// Whether the auction reached a fixed point inside the round bound.
261    pub converged: bool,
262    /// Whether every drone ends up believing the same thing.
263    pub consensus: bool,
264    /// Whether no task is claimed by two drones.
265    pub conflict_free: bool,
266    /// Final paths, by slot.
267    pub assignment: BTreeMap<u8, Vec<usize>>,
268}
269
270/// Run the auction to a fixed point over a broadcast medium: every drone hears
271/// every drone in every round.
272pub fn converge_broadcast(
273    agents: &mut [CbbaAgent],
274    tasks: &[CbbaTask],
275    roster: &[u8],
276) -> ConvergenceReport {
277    // Hard bound so a pathological input terminates. `N_min · D` with `D = 1` is
278    // the theoretical figure; this is generous by a whole factor of the fleet.
279    let bound = tasks.len() * agents.len() + 2;
280    let mut rounds = 0;
281    let mut converged = false;
282    for round in 1..=bound {
283        let mut changed = false;
284        for a in agents.iter_mut() {
285            changed |= a.build_bundle(tasks);
286        }
287        let snapshots: Vec<(u8, BidVector)> =
288            agents.iter().map(|a| (a.slot, a.vector.clone())).collect();
289        for a in agents.iter_mut() {
290            for (slot, v) in &snapshots {
291                if *slot == a.slot {
292                    continue;
293                }
294                changed |= a.receive(*slot, v, roster, round as u16);
295            }
296            changed |= a.release_lost();
297        }
298        if !changed {
299            converged = true;
300            break;
301        }
302        rounds = round;
303    }
304    ConvergenceReport {
305        rounds,
306        converged,
307        consensus: consensus_reached(agents),
308        conflict_free: conflicts(agents).is_empty(),
309        assignment: agents.iter().map(|a| (a.slot, a.path.to_vec())).collect(),
310    }
311}
312
313/// Tasks claimed by more than one drone.
314pub fn conflicts(agents: &[CbbaAgent]) -> Vec<usize> {
315    let mut count: BTreeMap<usize, usize> = BTreeMap::new();
316    for a in agents {
317        for &j in &a.path {
318            *count.entry(j).or_default() += 1;
319        }
320    }
321    count
322        .into_iter()
323        .filter(|(_, n)| *n > 1)
324        .map(|(j, _)| j)
325        .collect()
326}
327
328/// Whether every drone's winner vector agrees.
329pub fn consensus_reached(agents: &[CbbaAgent]) -> bool {
330    let mut iter = agents.iter();
331    let Some(first) = iter.next() else {
332        return true;
333    };
334    iter.all(|a| a.vector.z == first.vector.z)
335}
336
337#[cfg(test)]
338mod tests;