#![allow(clippy::needless_range_loop)]
use crate::core::matrix::Matrix;
use crate::core::scalar::ControlScalar;
use crate::networked::NetworkedError;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AgentGraph<S: ControlScalar, const N: usize> {
weights: Matrix<S, N, N>,
}
impl<S: ControlScalar, const N: usize> AgentGraph<S, N> {
pub fn new(weights: Matrix<S, N, N>) -> Result<Self, NetworkedError> {
if N < 2 {
return Err(NetworkedError::InsufficientAgents);
}
let tol = S::from_f64(1e-9);
for i in 0..N {
if weights.get(i, i).abs() > tol {
return Err(NetworkedError::InvalidTopology);
}
for j in 0..N {
if weights.get(i, j) < -tol {
return Err(NetworkedError::InvalidTopology);
}
let diff = (weights.get(i, j) - weights.get(j, i)).abs();
if diff > tol {
return Err(NetworkedError::InvalidTopology);
}
}
}
Ok(Self { weights })
}
pub fn laplacian(&self) -> Matrix<S, N, N> {
let mut l = Matrix::<S, N, N>::zeros();
for i in 0..N {
let mut degree = S::ZERO;
for j in 0..N {
let w = self.weights.get(i, j);
l.set(i, j, -w);
degree += w;
}
l.set(i, i, degree);
}
l
}
pub fn is_connected(&self) -> bool {
let l = self.laplacian();
matrix_rank(&l) == N - 1
}
pub fn num_agents(&self) -> usize {
N
}
pub fn weight(&self, i: usize, j: usize) -> S {
self.weights.get(i, j)
}
pub fn complete(w: S) -> Result<Self, NetworkedError> {
let mut weights = Matrix::<S, N, N>::zeros();
for i in 0..N {
for j in 0..N {
if i != j {
weights.set(i, j, w);
}
}
}
Self::new(weights)
}
}
fn matrix_rank<S: ControlScalar, const N: usize>(m: &Matrix<S, N, N>) -> usize {
let threshold = S::from_f64(1e-9);
let mut a: [[S; N]; N] = m.data;
let mut rank = 0usize;
let mut row = 0usize;
for col in 0..N {
let mut pivot_row = None;
let mut max_val = threshold;
for r in row..N {
let v = a[r][col].abs();
if v > max_val {
max_val = v;
pivot_row = Some(r);
}
}
let pivot_row = match pivot_row {
Some(r) => r,
None => continue, };
a.swap(row, pivot_row);
rank += 1;
let inv_pivot = S::ONE / a[row][col];
for c in col..N {
a[row][c] *= inv_pivot;
}
for r in 0..N {
if r == row {
continue;
}
let factor = a[r][col];
if factor.abs() <= threshold {
continue;
}
for c in col..N {
let sub = factor * a[row][c];
a[r][c] -= sub;
}
}
row += 1;
}
rank
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AverageConsensus<S: ControlScalar, const N: usize> {
graph: AgentGraph<S, N>,
step_size: S,
tol: S,
}
impl<S: ControlScalar, const N: usize> AverageConsensus<S, N> {
pub fn new(graph: AgentGraph<S, N>, step_size: S, tol: S) -> Result<Self, NetworkedError> {
if step_size <= S::ZERO {
return Err(NetworkedError::NumericalError);
}
if tol <= S::ZERO {
return Err(NetworkedError::NumericalError);
}
if !graph.is_connected() {
return Err(NetworkedError::InvalidTopology);
}
Ok(Self {
graph,
step_size,
tol,
})
}
pub fn step(&self, states: &mut [S; N]) -> [S; N] {
let old = *states;
for i in 0..N {
let mut sum = S::ZERO;
for j in 0..N {
let a_ij = self.graph.weight(i, j);
if a_ij > S::ZERO {
sum += a_ij * (old[i] - old[j]);
}
}
states[i] = old[i] - self.step_size * sum;
}
*states
}
pub fn has_converged(&self, states: &[S; N]) -> bool {
let (min_v, max_v) = min_max(states);
max_v - min_v < self.tol
}
pub fn consensus_error(&self, states: &[S; N]) -> S {
let (min_v, max_v) = min_max(states);
max_v - min_v
}
pub fn run_until_convergence(
&self,
states: &mut [S; N],
max_steps: usize,
) -> Result<usize, NetworkedError> {
for step in 0..max_steps {
self.step(states);
if self.has_converged(states) {
return Ok(step + 1);
}
}
Err(NetworkedError::NumericalError)
}
pub fn average(states: &[S; N]) -> S {
let mut sum = S::ZERO;
for &x in states.iter() {
sum += x;
}
sum / S::from_f64(N as f64)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LeaderFollowingConsensus<S: ControlScalar, const N: usize> {
graph: AgentGraph<S, N>,
pinning: [S; N],
step_size: S,
tol: S,
}
impl<S: ControlScalar, const N: usize> LeaderFollowingConsensus<S, N> {
pub fn new(
graph: AgentGraph<S, N>,
pinning: [S; N],
step_size: S,
tol: S,
) -> Result<Self, NetworkedError> {
if step_size <= S::ZERO || tol <= S::ZERO {
return Err(NetworkedError::NumericalError);
}
let any_pinned = pinning.iter().any(|&g| g > S::ZERO);
if !any_pinned {
return Err(NetworkedError::InvalidTopology);
}
Ok(Self {
graph,
pinning,
step_size,
tol,
})
}
pub fn step(&self, leader: S, states: &mut [S; N]) -> [S; N] {
let old = *states;
for i in 0..N {
let mut sum = S::ZERO;
for j in 0..N {
let a_ij = self.graph.weight(i, j);
if a_ij > S::ZERO {
sum += a_ij * (old[i] - old[j]);
}
}
sum += self.pinning[i] * (old[i] - leader);
states[i] = old[i] - self.step_size * sum;
}
*states
}
pub fn has_converged(&self, leader: S, states: &[S; N]) -> bool {
for &x in states.iter() {
if (x - leader).abs() >= self.tol {
return false;
}
}
true
}
pub fn tracking_error(&self, leader: S, states: &[S; N]) -> S {
let mut max_err = S::ZERO;
for &x in states.iter() {
let err = (x - leader).abs();
if err > max_err {
max_err = err;
}
}
max_err
}
pub fn run_until_convergence(
&self,
leader: S,
states: &mut [S; N],
max_steps: usize,
) -> Result<usize, NetworkedError> {
for step in 0..max_steps {
self.step(leader, states);
if self.has_converged(leader, states) {
return Ok(step + 1);
}
}
Err(NetworkedError::NumericalError)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DistributedGradientDescent<S: ControlScalar, const N: usize> {
graph: AgentGraph<S, N>,
grad_step: S,
consensus_weight: S,
tol: S,
}
impl<S: ControlScalar, const N: usize> DistributedGradientDescent<S, N> {
pub fn new(
graph: AgentGraph<S, N>,
grad_step: S,
consensus_weight: S,
tol: S,
) -> Result<Self, NetworkedError> {
if grad_step <= S::ZERO || consensus_weight <= S::ZERO || tol <= S::ZERO {
return Err(NetworkedError::NumericalError);
}
Ok(Self {
graph,
grad_step,
consensus_weight,
tol,
})
}
pub fn step(&self, states: &mut [S; N], gradients: &[S; N], step_size: S) -> [S; N] {
let old = *states;
for i in 0..N {
let mut consensus = S::ZERO;
for j in 0..N {
let a_ij = self.graph.weight(i, j);
if a_ij > S::ZERO {
consensus += a_ij * (old[i] - old[j]);
}
}
states[i] = old[i] - step_size * gradients[i] - self.consensus_weight * consensus;
}
*states
}
pub fn grad_step(&self) -> S {
self.grad_step
}
pub fn has_converged(&self, states: &[S; N]) -> bool {
let (min_v, max_v) = min_max(states);
max_v - min_v < self.tol
}
pub fn consensus_error(&self, states: &[S; N]) -> S {
let (min_v, max_v) = min_max(states);
max_v - min_v
}
}
fn min_max<S: ControlScalar, const N: usize>(v: &[S; N]) -> (S, S) {
let mut min_v = v[0];
let mut max_v = v[0];
for &x in v.iter().skip(1) {
if x < min_v {
min_v = x;
}
if x > max_v {
max_v = x;
}
}
(min_v, max_v)
}
#[cfg(test)]
mod tests {
use super::*;
fn ring_graph_4() -> AgentGraph<f64, 4> {
let mut w = Matrix::<f64, 4, 4>::zeros();
let edges = [(0, 1), (1, 2), (2, 3), (3, 0)];
for (i, j) in edges {
w.set(i, j, 1.0);
w.set(j, i, 1.0);
}
AgentGraph::new(w).expect("valid ring graph")
}
#[test]
fn laplacian_row_sums_to_zero() {
let g = ring_graph_4();
let l = g.laplacian();
for i in 0..4 {
let mut row_sum = 0.0_f64;
for j in 0..4 {
row_sum += l.get(i, j);
}
assert!(row_sum.abs() < 1e-10, "row {i} sum = {row_sum}");
}
}
#[test]
fn ring_graph_is_connected() {
let g = ring_graph_4();
assert!(g.is_connected());
}
#[test]
fn disconnected_graph_not_connected() {
let mut w = Matrix::<f64, 4, 4>::zeros();
w.set(0, 1, 1.0);
w.set(1, 0, 1.0);
w.set(2, 3, 1.0);
w.set(3, 2, 1.0);
let g = AgentGraph::new(w).expect("valid weights");
assert!(!g.is_connected());
}
#[test]
fn self_loop_rejected() {
let mut w = Matrix::<f64, 4, 4>::zeros();
w.set(0, 0, 1.0); w.set(0, 1, 1.0);
w.set(1, 0, 1.0);
assert_eq!(
AgentGraph::<f64, 4>::new(w),
Err(NetworkedError::InvalidTopology)
);
}
#[test]
fn asymmetric_weights_rejected() {
let mut w = Matrix::<f64, 4, 4>::zeros();
w.set(0, 1, 1.0);
w.set(1, 0, 2.0); assert_eq!(
AgentGraph::<f64, 4>::new(w),
Err(NetworkedError::InvalidTopology)
);
}
#[test]
fn complete_graph_is_connected() {
let g = AgentGraph::<f64, 4>::complete(1.0).expect("valid");
assert!(g.is_connected());
}
#[test]
fn average_consensus_converges_4_agents() {
let g = ring_graph_4();
let proto = AverageConsensus::new(g, 0.2, 1e-6).expect("valid protocol");
let mut states = [1.0_f64, 3.0, 5.0, 7.0];
let expected_avg = 4.0_f64;
let steps = proto
.run_until_convergence(&mut states, 500)
.expect("should converge");
assert!(steps <= 500, "converged in {steps} steps");
for (i, &x) in states.iter().enumerate() {
assert!(
(x - expected_avg).abs() < 1e-4,
"agent {i}: x={x:.6} expected={expected_avg}"
);
}
let avg_ref = AverageConsensus::<f64, 4>::average(&[1.0, 3.0, 5.0, 7.0]);
assert!((avg_ref - expected_avg).abs() < 1e-10);
let _ = proto; }
#[test]
fn average_consensus_conserves_sum() {
let g = ring_graph_4();
let proto = AverageConsensus::new(g, 0.2, 1e-8).expect("valid");
let mut states = [2.0_f64, -1.0, 4.0, 3.0];
let initial_sum: f64 = states.iter().sum();
for _ in 0..50 {
proto.step(&mut states);
}
let final_sum: f64 = states.iter().sum();
assert!(
(final_sum - initial_sum).abs() < 1e-8,
"sum changed: {initial_sum} → {final_sum}"
);
}
#[test]
fn average_consensus_converges_within_50_steps_complete_graph() {
let g = AgentGraph::<f64, 4>::complete(1.0).expect("valid");
let proto = AverageConsensus::new(g, 0.1, 1e-4).expect("valid");
let mut states = [0.0_f64, 10.0, 5.0, -5.0];
let expected_avg = 2.5_f64;
let steps = proto
.run_until_convergence(&mut states, 50)
.expect("should converge in 50 steps");
assert!(steps <= 50, "took {steps} steps");
for &x in states.iter() {
assert!((x - expected_avg).abs() < 1e-3, "x={x}");
}
}
#[test]
fn leader_following_tracks_leader() {
let g = AgentGraph::<f64, 4>::complete(1.0).expect("valid graph");
let pinning = [1.0_f64, 0.0, 0.0, 0.0];
let proto = LeaderFollowingConsensus::new(g, pinning, 0.2, 1e-4).expect("valid protocol");
let leader = 5.0_f64;
let mut states = [0.0_f64; 4];
let steps = proto
.run_until_convergence(leader, &mut states, 500)
.expect("should converge within 500 steps");
assert!(steps <= 500, "converged in {steps} steps");
for (i, &x) in states.iter().enumerate() {
assert!(
(x - leader).abs() < 1e-3,
"agent {i}: x={x:.4} leader={leader}"
);
}
}
#[test]
fn leader_following_no_pinning_rejected() {
let g = ring_graph_4();
let pinning = [0.0_f64; 4]; assert_eq!(
LeaderFollowingConsensus::<f64, 4>::new(g, pinning, 0.1, 1e-4),
Err(NetworkedError::InvalidTopology)
);
}
#[test]
fn distributed_gd_consensus_constant_gradient() {
let g = AgentGraph::<f64, 4>::complete(1.0).expect("valid");
let proto = DistributedGradientDescent::new(g, 0.01, 0.1, 1e-5).expect("valid");
let mut states = [1.0_f64, 2.0, 3.0, 4.0];
let gradients = [0.0_f64; 4];
for _ in 0..200 {
proto.step(&mut states, &gradients, 0.01);
if proto.has_converged(&states) {
break;
}
}
assert!(
proto.has_converged(&states),
"DGD should reach consensus with zero gradients"
);
}
#[test]
fn distributed_gd_quadratic_minimisation() {
let g = AgentGraph::<f64, 4>::complete(1.0).expect("valid");
let proto = DistributedGradientDescent::new(g, 0.005, 0.1, 0.1).expect("valid");
let targets = [1.0_f64, 2.0, 3.0, 4.0]; let expected = 2.5_f64;
let mut states = [0.0_f64; 4];
for _ in 0..2000 {
let gradients: [f64; 4] = core::array::from_fn(|i| 2.0 * (states[i] - targets[i]));
proto.step(&mut states, &gradients, 0.005);
}
assert!(
proto.has_converged(&states),
"DGD should achieve approximate consensus; spread={:.4}",
proto.consensus_error(&states)
);
let consensus_val = states.iter().copied().fold(0.0_f64, |s, x| s + x) / 4.0;
assert!(
(consensus_val - expected).abs() < 0.5,
"consensus value {consensus_val:.4} should be near {expected}"
);
}
#[test]
fn distributed_gd_invalid_params_rejected() {
let g = AgentGraph::<f64, 4>::complete(1.0).expect("valid");
assert_eq!(
DistributedGradientDescent::<f64, 4>::new(g, 0.0, 0.5, 1e-5),
Err(NetworkedError::NumericalError)
);
}
}