use super::*;
fn roster(n: usize) -> Vec<u8> {
(1..=n as u8).collect()
}
fn line_tasks(n: usize) -> Vec<CbbaTask> {
(0..n)
.map(|i| CbbaTask::new(format!("t{i}"), Ned::new(0.0, 100.0 * i as f64, 0.0), 10.0))
.collect()
}
fn agents_on_tasks(tasks: &[CbbaTask], n_agents: usize, capacity: usize) -> Vec<CbbaAgent> {
(0..n_agents)
.map(|i| {
CbbaAgent::new(i as u8 + 1, tasks[i].pos, tasks.len(), n_agents).with_capacity(capacity)
})
.collect()
}
#[test]
fn converges_in_one_round_when_greedy_bids_are_conflict_free() {
let tasks = line_tasks(4);
let mut agents = agents_on_tasks(&tasks, 4, 1);
let r = converge_broadcast(&mut agents, &tasks, &roster(4));
assert!(r.converged, "{r:?}");
assert_eq!(r.rounds, 1, "{r:?}");
assert!(r.conflict_free && r.consensus, "{r:?}");
for (slot, path) in &r.assignment {
assert_eq!(
path,
&vec![*slot as usize - 1],
"slot {slot} took its own task"
);
}
}
#[test]
fn a_fully_contested_auction_still_converges_conflict_free() {
let tasks = line_tasks(4);
let mut agents: Vec<CbbaAgent> = (1..=4u8)
.map(|slot| CbbaAgent::new(slot, tasks[0].pos, tasks.len(), 4).with_capacity(1))
.collect();
let r = converge_broadcast(&mut agents, &tasks, &roster(4));
assert!(r.converged, "{r:?}");
assert!(r.conflict_free, "conflicts remain: {r:?}");
assert!(r.consensus, "drones disagree: {r:?}");
assert!(
r.rounds <= tasks.len(),
"diameter-1 bound is the task count, took {} rounds",
r.rounds
);
let taken: Vec<usize> = r.assignment.values().flatten().copied().collect();
assert_eq!(taken.len(), 4, "every drone got exactly one task: {r:?}");
}
#[test]
fn every_task_is_claimed_at_most_once_across_fleet_sizes() {
for (n_agents, n_tasks, capacity) in [
(1, 1, 1),
(2, 5, 3),
(7, 7, 1),
(8, 20, 3),
(24, 20, 1),
(24, 24, 2),
] {
let tasks = line_tasks(n_tasks);
let mut agents: Vec<CbbaAgent> = (1..=n_agents as u8)
.map(|slot| {
let pos = Ned::new(50.0 * slot as f64, 130.0 * slot as f64, 0.0);
CbbaAgent::new(slot, pos, n_tasks, n_agents).with_capacity(capacity)
})
.collect();
let r = converge_broadcast(&mut agents, &tasks, &roster(n_agents));
assert!(r.converged, "n={n_agents} m={n_tasks}: {r:?}");
assert!(r.conflict_free, "n={n_agents} m={n_tasks}: {r:?}");
assert!(r.consensus, "n={n_agents} m={n_tasks}: {r:?}");
for path in r.assignment.values() {
assert!(path.len() <= capacity, "capacity exceeded: {path:?}");
}
}
}
#[test]
fn every_task_gets_taken_when_there_is_capacity_for_all_of_them() {
let tasks = line_tasks(6);
let mut agents: Vec<CbbaAgent> = (1..=3u8)
.map(|slot| {
CbbaAgent::new(slot, Ned::new(0.0, 180.0 * slot as f64, 0.0), 6, 3).with_capacity(2)
})
.collect();
let r = converge_broadcast(&mut agents, &tasks, &roster(3));
assert!(r.converged && r.conflict_free && r.consensus, "{r:?}");
let mut taken: Vec<usize> = r.assignment.values().flatten().copied().collect();
taken.sort_unstable();
assert_eq!(
taken,
vec![0, 1, 2, 3, 4, 5],
"no task left on the table: {r:?}"
);
}
#[test]
fn the_nearer_drone_wins_a_contested_task() {
let tasks = line_tasks(1);
let mut agents = vec![
CbbaAgent::new(1, Ned::new(0.0, 400.0, 0.0), 1, 2).with_capacity(1),
CbbaAgent::new(2, tasks[0].pos, 1, 2).with_capacity(1),
];
let r = converge_broadcast(&mut agents, &tasks, &roster(2));
assert!(r.converged && r.conflict_free, "{r:?}");
assert_eq!(r.assignment[&2], vec![0], "the drone on the spot wins");
assert!(r.assignment[&1].is_empty());
}
#[test]
fn a_bundle_is_ordered_by_travel_cost_not_by_task_index() {
let tasks = vec![
CbbaTask::new("far", Ned::new(0.0, 300.0, 0.0), 10.0),
CbbaTask::new("mid", Ned::new(0.0, 200.0, 0.0), 10.0),
CbbaTask::new("near", Ned::new(0.0, 100.0, 0.0), 10.0),
];
let mut a = CbbaAgent::new(1, Ned::ZERO, 3, 1).with_capacity(3);
a.build_bundle(&tasks);
let order: Vec<&str> = a.path().iter().map(|&j| tasks[j].id.as_str()).collect();
assert_eq!(
order,
vec!["near", "mid", "far"],
"path must be a cheap tour"
);
assert_eq!(a.bundle().len(), 3);
}
#[test]
fn losing_a_task_releases_it_and_everything_added_after_it() {
let tasks = line_tasks(3);
let mut a = CbbaAgent::new(1, Ned::ZERO, 3, 2).with_capacity(3);
a.build_bundle(&tasks);
assert_eq!(a.bundle().len(), 3);
let first_added = a.bundle()[0];
let last_added = a.bundle()[2];
let mut theirs = BidVector::new(3, 2);
theirs.y[first_added] = 1e6;
theirs.z[first_added] = Some(2);
assert!(a.receive(2, &theirs, &roster(2), 1));
assert!(a.release_lost());
assert!(
a.bundle().is_empty(),
"the whole tail goes: {:?}",
a.bundle()
);
assert!(a.path().is_empty());
assert_eq!(a.vector().z[last_added], None);
assert_eq!(a.vector().y[last_added], 0.0);
assert_eq!(a.vector().z[first_added], Some(2));
}
#[test]
fn releasing_keeps_tasks_added_before_the_lost_one() {
let tasks = line_tasks(3);
let mut a = CbbaAgent::new(1, Ned::ZERO, 3, 2).with_capacity(3);
a.build_bundle(&tasks);
let kept = a.bundle()[0];
let lost = a.bundle()[1];
let mut theirs = BidVector::new(3, 2);
theirs.y[lost] = 1e6;
theirs.z[lost] = Some(2);
a.receive(2, &theirs, &roster(2), 1);
a.release_lost();
assert_eq!(a.bundle(), &[kept], "only the tail is released");
assert!(a.path().contains(&kept));
assert!(!a.path().contains(&lost));
assert!(!a.release_lost(), "nothing left to release");
}
#[test]
fn a_peer_auctioning_a_different_problem_is_ignored() {
let tasks = line_tasks(3);
let mut a = CbbaAgent::new(1, Ned::ZERO, 3, 2).with_capacity(1);
a.build_bundle(&tasks);
let before = a.vector().clone();
assert!(!a.receive(2, &BidVector::new(2, 2), &roster(2), 1));
assert!(!a.receive(2, &BidVector::new(3, 5), &roster(2), 1));
assert_eq!(a.vector(), &before);
}
#[test]
fn an_empty_task_set_settles_immediately() {
let mut agents: Vec<CbbaAgent> = (1..=3u8)
.map(|s| CbbaAgent::new(s, Ned::ZERO, 0, 3))
.collect();
let r = converge_broadcast(&mut agents, &[], &roster(3));
assert!(r.converged);
assert_eq!(r.rounds, 0, "nothing to auction is not a round: {r:?}");
assert!(r.conflict_free && r.consensus);
for path in r.assignment.values() {
assert!(path.is_empty());
}
}
#[test]
fn a_lone_drone_takes_what_it_can_carry_and_no_more() {
let tasks = line_tasks(9);
let mut agents = vec![CbbaAgent::new(1, Ned::ZERO, 9, 1).with_capacity(4)];
let r = converge_broadcast(&mut agents, &tasks, &roster(1));
assert!(r.converged, "{r:?}");
assert_eq!(r.assignment[&1].len(), 4);
assert_eq!(r.rounds, 1);
}
#[test]
fn conflicts_and_consensus_detect_a_genuine_split() {
let tasks = line_tasks(2);
let mut a = CbbaAgent::new(1, tasks[0].pos, 2, 2).with_capacity(1);
let mut b = CbbaAgent::new(2, tasks[0].pos, 2, 2).with_capacity(1);
a.build_bundle(&tasks);
b.build_bundle(&tasks);
assert_eq!(conflicts(&[a.clone(), b.clone()]), vec![0]);
assert!(!consensus_reached(&[a.clone(), b.clone()]));
assert!(consensus_reached(&[]));
assert!(consensus_reached(std::slice::from_ref(&a)));
}
#[test]
fn assignment_surfaces_the_next_task_and_its_bundle_position() {
let tasks = vec![
CbbaTask::new("beta", Ned::new(0.0, 400.0, 0.0), 10.0),
CbbaTask::new("alpha", Ned::new(0.0, 100.0, 0.0), 10.0),
];
let mut a = CbbaAgent::new(1, Ned::ZERO, 2, 1).with_capacity(2);
assert_eq!(a.assignment(&tasks), TaskAssignment::default());
a.build_bundle(&tasks);
let got = a.assignment(&tasks);
assert_eq!(
got.task_id.as_deref(),
Some("alpha"),
"the NEXT task, not the first bid"
);
assert_eq!(got.bundle_position, Some(0), "alpha was also added first");
}
#[test]
fn the_score_is_time_discounted_so_a_closer_task_is_worth_more() {
let near = vec![CbbaTask::new("n", Ned::new(0.0, 10.0, 0.0), 10.0)];
let far = vec![CbbaTask::new("f", Ned::new(0.0, 1000.0, 0.0), 10.0)];
let s_near = path_score(Ned::ZERO, CBBA_SPEED_MPS, CBBA_DISCOUNT, &near, &[0]);
let s_far = path_score(Ned::ZERO, CBBA_SPEED_MPS, CBBA_DISCOUNT, &far, &[0]);
assert!(s_near > s_far, "{s_near} vs {s_far}");
assert!(s_far > 0.0, "discounting must not go negative: {s_far}");
let both = vec![
CbbaTask::new("east", Ned::new(0.0, 200.0, 0.0), 10.0),
CbbaTask::new("north", Ned::new(200.0, 0.0, 0.0), 10.0),
];
let east_only = path_score(Ned::ZERO, CBBA_SPEED_MPS, CBBA_DISCOUNT, &both, &[0]);
let north_alone = path_score(Ned::ZERO, CBBA_SPEED_MPS, CBBA_DISCOUNT, &both, &[1]);
let marginal = path_score(Ned::ZERO, CBBA_SPEED_MPS, CBBA_DISCOUNT, &both, &[0, 1]) - east_only;
assert!(
marginal < north_alone * 0.9,
"marginal {marginal} should be well under {north_alone}"
);
assert!(marginal > 0.0, "a reachable task still has positive value");
assert!(path_score(Ned::ZERO, 0.0, CBBA_DISCOUNT, &near, &[0]).is_finite());
assert!(path_score(Ned::ZERO, f64::NAN, CBBA_DISCOUNT, &near, &[0]).is_finite());
}
#[test]
fn a_bid_vector_from_the_wire_drives_the_same_decision_as_the_local_one() {
let tasks = line_tasks(3);
let mut a = CbbaAgent::new(1, Ned::ZERO, 3, 2).with_capacity(2);
let mut b = CbbaAgent::new(2, tasks[2].pos, 3, 2).with_capacity(2);
a.build_bundle(&tasks);
b.build_bundle(&tasks);
let mut direct = a.clone();
let mut wired = a.clone();
let on_wire = BidVector::decode(&b.vector().encode(), 3, 2).expect("round trip");
assert!(
direct.receive(2, b.vector(), &roster(2), 1) == wired.receive(2, &on_wire, &roster(2), 1)
);
assert_eq!(direct.vector(), wired.vector());
}