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;