Skip to main content

rust_robotics_control/
admm_consensus.rs

1//! Distributed formation consensus via ADMM.
2//!
3//! A pure-Rust reproduction slice for the coordination layer of distributed MPC
4//! (in the spirit of ACLM-style consensus control): a team of agents agrees on a
5//! common formation center `z` while each agent settles at `z + offset_i`, trades
6//! off staying near its own preferred position, and respects a per-agent box
7//! constraint. The agreement is reached by the classic *consensus ADMM*
8//! iteration — each agent solves a tiny local problem, the team averages to a
9//! consensus, and scaled dual variables drive the per-agent residuals to zero.
10//!
11//! Minimize `sum_i (w_i/2) ||x_i - a_i||^2` subject to `x_i - offset_i = z` and
12//! `lower_i <= x_i <= upper_i`. With equal weights and no box constraints the
13//! consensus center has the closed form `z* = mean(a_i - offset_i)`, which the
14//! solver recovers; box constraints make it iterative and is where ADMM earns its
15//! keep.
16//!
17//! - [`AgentSpec`] is one agent's preferred position, formation offset, weight,
18//!   and box bounds.
19//! - [`AdmmConfig`] holds the penalty `rho`, the iteration cap, and the residual
20//!   tolerance.
21//! - [`solve_formation_consensus`] runs (centralized) consensus ADMM and returns
22//!   the agent positions, the consensus center, and the primal/dual residuals.
23//! - [`solve_graph_consensus`] runs the *decentralized* edge-based variant where
24//!   agents agree only with communication-graph neighbors (no global average);
25//!   the convergence rate is set by the graph connectivity.
26//! - [`solve_horizon_consensus`] is the *receding-horizon* variant: agents agree
27//!   on a shared center **trajectory** over a short horizon (rather than a single
28//!   static center), with a temporal smoothness penalty that couples the center
29//!   across time and turns the consensus z-update into a banded linear solve.
30
31use rust_robotics_core::{RoboticsError, RoboticsResult};
32
33/// One agent in the formation-consensus problem.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct AgentSpec {
36    /// Preferred position `a_i` the agent is pulled toward.
37    pub preferred: [f64; 2],
38    /// Formation offset `offset_i` from the shared center.
39    pub offset: [f64; 2],
40    /// Cost weight on staying near `preferred` (relative to the consensus pull).
41    pub weight: f64,
42    /// Lower box bound on the agent position (use `f64::NEG_INFINITY` to disable).
43    pub lower: [f64; 2],
44    /// Upper box bound on the agent position (use `f64::INFINITY` to disable).
45    pub upper: [f64; 2],
46}
47
48impl AgentSpec {
49    /// An unconstrained agent with unit weight.
50    pub fn new(preferred: [f64; 2], offset: [f64; 2]) -> Self {
51        Self {
52            preferred,
53            offset,
54            weight: 1.0,
55            lower: [f64::NEG_INFINITY; 2],
56            upper: [f64::INFINITY; 2],
57        }
58    }
59
60    /// Set a box constraint on the agent's position.
61    pub fn with_box(mut self, lower: [f64; 2], upper: [f64; 2]) -> Self {
62        self.lower = lower;
63        self.upper = upper;
64        self
65    }
66
67    /// Set the cost weight on staying near `preferred`.
68    pub fn with_weight(mut self, weight: f64) -> Self {
69        self.weight = weight;
70        self
71    }
72}
73
74/// Consensus-ADMM solver settings.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct AdmmConfig {
77    /// Augmented-Lagrangian penalty parameter.
78    pub rho: f64,
79    /// Maximum number of ADMM iterations.
80    pub max_iters: usize,
81    /// Stop once both residual norms fall below this tolerance.
82    pub tol: f64,
83}
84
85impl Default for AdmmConfig {
86    fn default() -> Self {
87        Self {
88            rho: 1.0,
89            max_iters: 200,
90            tol: 1e-6,
91        }
92    }
93}
94
95/// Result of a consensus-ADMM solve.
96#[derive(Debug, Clone, PartialEq)]
97pub struct AdmmReport {
98    /// Final agent positions `x_i`.
99    pub positions: Vec<[f64; 2]>,
100    /// Consensus center `z`.
101    pub center: [f64; 2],
102    /// Primal residual norm per iteration.
103    pub primal_residuals: Vec<f64>,
104    /// Dual residual norm per iteration.
105    pub dual_residuals: Vec<f64>,
106    /// Iterations actually run.
107    pub iterations: usize,
108}
109
110fn clamp2(v: [f64; 2], lo: [f64; 2], hi: [f64; 2]) -> [f64; 2] {
111    [v[0].clamp(lo[0], hi[0]), v[1].clamp(lo[1], hi[1])]
112}
113
114/// Solve the formation-consensus problem with consensus ADMM.
115pub fn solve_formation_consensus(
116    config: AdmmConfig,
117    agents: &[AgentSpec],
118) -> RoboticsResult<AdmmReport> {
119    if agents.is_empty() {
120        return Err(RoboticsError::InvalidParameter(
121            "consensus ADMM needs at least one agent".to_string(),
122        ));
123    }
124    if !config.rho.is_finite() || config.rho <= 0.0 {
125        return Err(RoboticsError::InvalidParameter(
126            "consensus ADMM rho must be finite and positive".to_string(),
127        ));
128    }
129    if config.max_iters == 0 {
130        return Err(RoboticsError::InvalidParameter(
131            "consensus ADMM max_iters must be positive".to_string(),
132        ));
133    }
134    for a in agents {
135        if !a.weight.is_finite() || a.weight <= 0.0 {
136            return Err(RoboticsError::InvalidParameter(
137                "consensus ADMM agent weight must be finite and positive".to_string(),
138            ));
139        }
140        if a.lower[0] > a.upper[0] || a.lower[1] > a.upper[1] {
141            return Err(RoboticsError::InvalidParameter(
142                "consensus ADMM agent box lower must not exceed upper".to_string(),
143            ));
144        }
145    }
146
147    let n = agents.len();
148    let rho = config.rho;
149    let mut x = vec![[0.0; 2]; n];
150    let mut u = vec![[0.0; 2]; n];
151    // Initialize the center at the mean preferred-minus-offset.
152    let mut z = [0.0; 2];
153    for a in agents {
154        z[0] += (a.preferred[0] - a.offset[0]) / n as f64;
155        z[1] += (a.preferred[1] - a.offset[1]) / n as f64;
156    }
157    for (xi, a) in x.iter_mut().zip(agents) {
158        *xi = clamp2([z[0] + a.offset[0], z[1] + a.offset[1]], a.lower, a.upper);
159    }
160
161    let mut primal_residuals = Vec::new();
162    let mut dual_residuals = Vec::new();
163    let mut iterations = 0;
164
165    for _ in 0..config.max_iters {
166        iterations += 1;
167        // x-update: local proximal step toward preferred and the consensus, then
168        // project onto the box.
169        for (xi, (a, ui)) in x.iter_mut().zip(agents.iter().zip(u.iter())) {
170            let w = a.weight;
171            for k in 0..2 {
172                let target = (w * a.preferred[k] + rho * (z[k] + a.offset[k] - ui[k])) / (w + rho);
173                xi[k] = target;
174            }
175            *xi = clamp2(*xi, a.lower, a.upper);
176        }
177
178        // z-update: consensus average of x_i - offset_i + u_i.
179        let z_prev = z;
180        z = [0.0; 2];
181        for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter())) {
182            for k in 0..2 {
183                z[k] += (xi[k] - a.offset[k] + ui[k]) / n as f64;
184            }
185        }
186
187        // u-update and primal residual.
188        let mut primal_sq = 0.0;
189        for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter_mut())) {
190            for k in 0..2 {
191                let r = xi[k] - z[k] - a.offset[k];
192                ui[k] += r;
193                primal_sq += r * r;
194            }
195        }
196        let dz0 = z[0] - z_prev[0];
197        let dz1 = z[1] - z_prev[1];
198        let dual = rho * ((n as f64) * (dz0 * dz0 + dz1 * dz1)).sqrt();
199        let primal = primal_sq.sqrt();
200        primal_residuals.push(primal);
201        dual_residuals.push(dual);
202
203        if primal < config.tol && dual < config.tol {
204            break;
205        }
206    }
207
208    Ok(AdmmReport {
209        positions: x,
210        center: z,
211        primal_residuals,
212        dual_residuals,
213        iterations,
214    })
215}
216
217/// Result of a decentralized (graph) consensus-ADMM solve.
218#[derive(Debug, Clone, PartialEq)]
219pub struct GraphConsensusReport {
220    /// Final agent positions `x_i`.
221    pub positions: Vec<[f64; 2]>,
222    /// Mean consensus value (the agreed formation center).
223    pub center: [f64; 2],
224    /// Edge-disagreement norm per iteration (`sqrt(sum_edges ||y_i - y_j||^2)`).
225    pub disagreement: Vec<f64>,
226    /// Iterations actually run.
227    pub iterations: usize,
228}
229
230/// Solve formation consensus *decentralized* over a communication graph, where
231/// each agent exchanges only with its graph neighbors (no global average).
232///
233/// This is the standard edge-based decentralized ADMM for
234/// `minimize sum_i (w_i/2)||x_i - a_i||^2  s.t.  x_i - offset_i = x_j - offset_j`
235/// for every edge `(i, j)`. Each agent keeps an aggregated dual `alpha_i` over
236/// its incident edges and updates
237/// `y_i = (w a_i' - alpha_i + rho * sum_{j in N_i}(y_i + y_j)) / (w + 2 rho deg_i)`
238/// using only neighbor values `y_j` (with `a_i' = a_i - offset_i` and the
239/// position recovered as `x_i = y_i + offset_i`, projected onto the box). On a
240/// connected graph it converges to the same weighted-average consensus as the
241/// centralized solver; the convergence rate is set by the graph connectivity.
242#[allow(clippy::needless_range_loop)]
243pub fn solve_graph_consensus(
244    config: AdmmConfig,
245    agents: &[AgentSpec],
246    edges: &[(usize, usize)],
247) -> RoboticsResult<GraphConsensusReport> {
248    if agents.is_empty() {
249        return Err(RoboticsError::InvalidParameter(
250            "graph consensus needs at least one agent".to_string(),
251        ));
252    }
253    if !config.rho.is_finite() || config.rho <= 0.0 || config.max_iters == 0 {
254        return Err(RoboticsError::InvalidParameter(
255            "graph consensus rho must be positive and max_iters > 0".to_string(),
256        ));
257    }
258    let n = agents.len();
259    for a in agents {
260        if !a.weight.is_finite() || a.weight <= 0.0 {
261            return Err(RoboticsError::InvalidParameter(
262                "graph consensus agent weight must be finite and positive".to_string(),
263            ));
264        }
265    }
266    let mut neighbors = vec![Vec::new(); n];
267    for &(i, j) in edges {
268        if i >= n || j >= n || i == j {
269            return Err(RoboticsError::InvalidParameter(
270                "graph consensus edge endpoints out of range or self-loop".to_string(),
271            ));
272        }
273        neighbors[i].push(j);
274        neighbors[j].push(i);
275    }
276
277    let rho = config.rho;
278    // Shifted preferred values a_i' = a_i - offset_i; consensus is on y_i.
279    let ashift: Vec<[f64; 2]> = agents
280        .iter()
281        .map(|a| [a.preferred[0] - a.offset[0], a.preferred[1] - a.offset[1]])
282        .collect();
283    let mut y = ashift.clone();
284    let mut alpha = vec![[0.0; 2]; n];
285
286    let project = |y_i: [f64; 2], a: &AgentSpec| -> [f64; 2] {
287        let x = clamp2(
288            [y_i[0] + a.offset[0], y_i[1] + a.offset[1]],
289            a.lower,
290            a.upper,
291        );
292        [x[0] - a.offset[0], x[1] - a.offset[1]]
293    };
294
295    let mut disagreement = Vec::new();
296    let mut iterations = 0;
297
298    for _ in 0..config.max_iters {
299        iterations += 1;
300        let y_prev = y.clone();
301        // Synchronous local updates using only neighbor values.
302        for i in 0..n {
303            let w = agents[i].weight;
304            let deg = neighbors[i].len() as f64;
305            for k in 0..2 {
306                let mut neigh_sum = 0.0;
307                for &j in &neighbors[i] {
308                    neigh_sum += y_prev[i][k] + y_prev[j][k];
309                }
310                y[i][k] =
311                    (w * ashift[i][k] - alpha[i][k] + rho * neigh_sum) / (w + 2.0 * rho * deg);
312            }
313            y[i] = project(y[i], &agents[i]);
314        }
315        // Dual update from the updated disagreements.
316        for i in 0..n {
317            for k in 0..2 {
318                let mut diff = 0.0;
319                for &j in &neighbors[i] {
320                    diff += y[i][k] - y[j][k];
321                }
322                alpha[i][k] += rho * diff;
323            }
324        }
325        // Edge-disagreement and step size.
326        let mut dis_sq = 0.0;
327        for &(i, j) in edges {
328            for k in 0..2 {
329                let d = y[i][k] - y[j][k];
330                dis_sq += d * d;
331            }
332        }
333        let mut step_sq = 0.0;
334        for i in 0..n {
335            for k in 0..2 {
336                let d = y[i][k] - y_prev[i][k];
337                step_sq += d * d;
338            }
339        }
340        disagreement.push(dis_sq.sqrt());
341        if dis_sq.sqrt() < config.tol && step_sq.sqrt() < config.tol {
342            break;
343        }
344    }
345
346    let mut center = [0.0; 2];
347    for yi in &y {
348        center[0] += yi[0] / n as f64;
349        center[1] += yi[1] / n as f64;
350    }
351    let positions: Vec<[f64; 2]> = y
352        .iter()
353        .zip(agents)
354        .map(|(yi, a)| [yi[0] + a.offset[0], yi[1] + a.offset[1]])
355        .collect();
356
357    Ok(GraphConsensusReport {
358        positions,
359        center,
360        disagreement,
361        iterations,
362    })
363}
364
365/// One agent's planning slice in the receding-horizon (trajectory) consensus
366/// problem: a per-step reference trajectory, a constant formation offset, a cost
367/// weight, and an optional box constraint applied at every step.
368#[derive(Debug, Clone, PartialEq)]
369pub struct AgentTrajectory {
370    /// Per-step reference positions `a_i[t]` the agent is pulled toward.
371    pub reference: Vec<[f64; 2]>,
372    /// Constant formation offset `offset_i` from the shared center trajectory.
373    pub offset: [f64; 2],
374    /// Cost weight on staying near the reference (relative to the consensus pull).
375    pub weight: f64,
376    /// Lower box bound, applied to every step (`f64::NEG_INFINITY` disables).
377    pub lower: [f64; 2],
378    /// Upper box bound, applied to every step (`f64::INFINITY` disables).
379    pub upper: [f64; 2],
380}
381
382impl AgentTrajectory {
383    /// An unconstrained agent with unit weight tracking `reference`.
384    pub fn new(reference: Vec<[f64; 2]>, offset: [f64; 2]) -> Self {
385        Self {
386            reference,
387            offset,
388            weight: 1.0,
389            lower: [f64::NEG_INFINITY; 2],
390            upper: [f64::INFINITY; 2],
391        }
392    }
393
394    /// Set a per-step box constraint on the agent's position.
395    pub fn with_box(mut self, lower: [f64; 2], upper: [f64; 2]) -> Self {
396        self.lower = lower;
397        self.upper = upper;
398        self
399    }
400
401    /// Set the cost weight on tracking the reference.
402    pub fn with_weight(mut self, weight: f64) -> Self {
403        self.weight = weight;
404        self
405    }
406}
407
408/// Result of a receding-horizon (trajectory) consensus-ADMM solve.
409#[derive(Debug, Clone, PartialEq)]
410pub struct HorizonConsensusReport {
411    /// Shared center trajectory `z[t]`, length `H`.
412    pub center: Vec<[f64; 2]>,
413    /// Per-agent trajectories `x_i[t]`.
414    pub trajectories: Vec<Vec<[f64; 2]>>,
415    /// Primal residual norm per iteration (`sqrt(sum_{i,t} ||x_i[t]-z[t]-offset_i||^2)`).
416    pub primal_residuals: Vec<f64>,
417    /// Dual residual norm per iteration.
418    pub dual_residuals: Vec<f64>,
419    /// Iterations actually run.
420    pub iterations: usize,
421}
422
423/// Dense Cholesky factorization `A = L L^T` of a symmetric positive-definite
424/// matrix; returns the lower-triangular `L`. Panics only on a non-SPD matrix,
425/// which the construction here precludes.
426#[allow(clippy::needless_range_loop)]
427fn cholesky(a: &[Vec<f64>]) -> Vec<Vec<f64>> {
428    let m = a.len();
429    let mut l = vec![vec![0.0; m]; m];
430    for i in 0..m {
431        for j in 0..=i {
432            let mut sum = a[i][j];
433            for k in 0..j {
434                sum -= l[i][k] * l[j][k];
435            }
436            if i == j {
437                l[i][j] = sum.max(0.0).sqrt();
438            } else {
439                l[i][j] = sum / l[j][j];
440            }
441        }
442    }
443    l
444}
445
446/// Solve `L L^T x = b` in place given the Cholesky factor `L`.
447fn chol_solve(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
448    let m = l.len();
449    let mut y = vec![0.0; m];
450    for i in 0..m {
451        let mut sum = b[i];
452        for k in 0..i {
453            sum -= l[i][k] * y[k];
454        }
455        y[i] = sum / l[i][i];
456    }
457    let mut x = vec![0.0; m];
458    for i in (0..m).rev() {
459        let mut sum = y[i];
460        for k in (i + 1)..m {
461            sum -= l[k][i] * x[k];
462        }
463        x[i] = sum / l[i][i];
464    }
465    x
466}
467
468/// Solve the *receding-horizon* formation-consensus problem: agents agree on a
469/// shared center **trajectory** `z[0..H]` rather than a single static center.
470///
471/// Each agent tracks a per-step reference `a_i[t]`, sits at `z[t] + offset_i`,
472/// and respects a per-step box; the shared center carries a temporal-smoothness
473/// (acceleration) penalty `(smooth_weight/2) sum_t ||z[t+1]-2 z[t]+z[t-1]||^2`
474/// that couples the center across time. The full problem is
475/// `minimize sum_{i,t} (w_i/2)||x_i[t]-a_i[t]||^2 + (smooth_weight/2) sum_t
476/// ||z[t+1]-2z[t]+z[t-1]||^2  s.t.  x_i[t] - offset_i = z[t]` with per-step boxes.
477///
478/// Consensus ADMM mirrors [`solve_formation_consensus`], but the z-update is no
479/// longer a per-step average: the smoothness term makes it a banded
480/// (pentadiagonal) symmetric-positive-definite linear solve
481/// `(rho N I + smooth_weight D^T D) z = rho sum_i (x_i - offset_i + u_i)` per
482/// coordinate, where `D` is the second-difference operator. The system matrix is
483/// constant across iterations, so it is Cholesky-factorized once and back-solved
484/// each iteration. An optional `anchor` fixes `z[0]` (the current team center),
485/// which is what makes the solve usable as the inner step of a receding-horizon
486/// MPC loop: solve over the horizon, apply `z[1]`, shift, and re-solve.
487///
488/// With `smooth_weight = 0` and a one-step horizon this reduces exactly to the
489/// static centralized consensus of [`solve_formation_consensus`].
490#[allow(clippy::needless_range_loop)]
491pub fn solve_horizon_consensus(
492    config: AdmmConfig,
493    agents: &[AgentTrajectory],
494    smooth_weight: f64,
495    anchor: Option<[f64; 2]>,
496) -> RoboticsResult<HorizonConsensusReport> {
497    if agents.is_empty() {
498        return Err(RoboticsError::InvalidParameter(
499            "horizon consensus needs at least one agent".to_string(),
500        ));
501    }
502    if !config.rho.is_finite() || config.rho <= 0.0 || config.max_iters == 0 {
503        return Err(RoboticsError::InvalidParameter(
504            "horizon consensus rho must be positive and max_iters > 0".to_string(),
505        ));
506    }
507    if !smooth_weight.is_finite() || smooth_weight < 0.0 {
508        return Err(RoboticsError::InvalidParameter(
509            "horizon consensus smooth_weight must be finite and non-negative".to_string(),
510        ));
511    }
512    let horizon = agents[0].reference.len();
513    if horizon == 0 {
514        return Err(RoboticsError::InvalidParameter(
515            "horizon consensus reference trajectories must be non-empty".to_string(),
516        ));
517    }
518    for a in agents {
519        if a.reference.len() != horizon {
520            return Err(RoboticsError::InvalidParameter(
521                "horizon consensus reference trajectories must share one length".to_string(),
522            ));
523        }
524        if !a.weight.is_finite() || a.weight <= 0.0 {
525            return Err(RoboticsError::InvalidParameter(
526                "horizon consensus agent weight must be finite and positive".to_string(),
527            ));
528        }
529        if a.lower[0] > a.upper[0] || a.lower[1] > a.upper[1] {
530            return Err(RoboticsError::InvalidParameter(
531                "horizon consensus agent box lower must not exceed upper".to_string(),
532            ));
533        }
534    }
535    if let Some(p) = anchor {
536        if !p[0].is_finite() || !p[1].is_finite() {
537            return Err(RoboticsError::InvalidParameter(
538                "horizon consensus anchor must be finite".to_string(),
539            ));
540        }
541    }
542
543    let n = agents.len();
544    let rho = config.rho;
545    let anchored = anchor.is_some();
546
547    // Build the dense z-update system matrix A = rho N I + smooth_weight D^T D,
548    // where D is the second-difference (acceleration) operator. A is constant
549    // across ADMM iterations, so it is factorized once below.
550    let mut a_mat = vec![vec![0.0; horizon]; horizon];
551    for t in 0..horizon {
552        a_mat[t][t] += rho * n as f64;
553    }
554    if smooth_weight > 0.0 {
555        // One acceleration row per interior step t: coeffs (1,-2,1) at (t-1,t,t+1).
556        for t in 1..horizon.saturating_sub(1) {
557            let idx = [t - 1, t, t + 1];
558            let coeff = [1.0, -2.0, 1.0];
559            for (a_i, &ia) in idx.iter().enumerate() {
560                for (b_i, &ib) in idx.iter().enumerate() {
561                    a_mat[ia][ib] += smooth_weight * coeff[a_i] * coeff[b_i];
562                }
563            }
564        }
565    }
566
567    // Free indices: all steps, or steps 1.. when z[0] is anchored.
568    let free: Vec<usize> = if anchored {
569        (1..horizon).collect()
570    } else {
571        (0..horizon).collect()
572    };
573    let m = free.len();
574    // Reduced system over the free indices (anchored z[0] moves to the RHS).
575    let mut a_red = vec![vec![0.0; m]; m];
576    for (r, &ir) in free.iter().enumerate() {
577        for (c, &ic) in free.iter().enumerate() {
578            a_red[r][c] = a_mat[ir][ic];
579        }
580    }
581    // When m == 0 (anchored, horizon == 1) the whole trajectory is the anchor.
582    let chol = if m > 0 { cholesky(&a_red) } else { Vec::new() };
583
584    // Initialize the center trajectory at the per-step mean reference-minus-offset.
585    let mut z = vec![[0.0; 2]; horizon];
586    for t in 0..horizon {
587        for a in agents {
588            z[t][0] += (a.reference[t][0] - a.offset[0]) / n as f64;
589            z[t][1] += (a.reference[t][1] - a.offset[1]) / n as f64;
590        }
591    }
592    if let Some(p) = anchor {
593        z[0] = p;
594    }
595    let mut x: Vec<Vec<[f64; 2]>> = agents
596        .iter()
597        .map(|a| {
598            (0..horizon)
599                .map(|t| {
600                    clamp2(
601                        [z[t][0] + a.offset[0], z[t][1] + a.offset[1]],
602                        a.lower,
603                        a.upper,
604                    )
605                })
606                .collect()
607        })
608        .collect();
609    let mut u = vec![vec![[0.0; 2]; horizon]; n];
610
611    let mut primal_residuals = Vec::new();
612    let mut dual_residuals = Vec::new();
613    let mut iterations = 0;
614
615    for _ in 0..config.max_iters {
616        iterations += 1;
617        // x-update: per agent, per step — local proximal step then box projection.
618        for (xi, (a, ui)) in x.iter_mut().zip(agents.iter().zip(u.iter())) {
619            let w = a.weight;
620            for t in 0..horizon {
621                for k in 0..2 {
622                    xi[t][k] = (w * a.reference[t][k] + rho * (z[t][k] + a.offset[k] - ui[t][k]))
623                        / (w + rho);
624                }
625                xi[t] = clamp2(xi[t], a.lower, a.upper);
626            }
627        }
628
629        // z-update: banded SPD solve per coordinate (couples the center in time).
630        let z_prev = z.clone();
631        for k in 0..2 {
632            // RHS b[t] = rho * sum_i (x_i[t] - offset_i + u_i[t]).
633            let mut b_full = vec![0.0; horizon];
634            for t in 0..horizon {
635                let mut s = 0.0;
636                for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter())) {
637                    s += xi[t][k] - a.offset[k] + ui[t][k];
638                }
639                b_full[t] = rho * s;
640            }
641            if m == 0 {
642                continue;
643            }
644            // Reduce: move the anchored z[0] contribution to the RHS.
645            let mut b_red = vec![0.0; m];
646            for (r, &ir) in free.iter().enumerate() {
647                let mut v = b_full[ir];
648                if anchored {
649                    v -= a_mat[ir][0] * z[0][k];
650                }
651                b_red[r] = v;
652            }
653            let sol = chol_solve(&chol, &b_red);
654            for (r, &ir) in free.iter().enumerate() {
655                z[ir][k] = sol[r];
656            }
657        }
658
659        // dual update and primal residual.
660        let mut primal_sq = 0.0;
661        for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter_mut())) {
662            for t in 0..horizon {
663                for k in 0..2 {
664                    let r = xi[t][k] - z[t][k] - a.offset[k];
665                    ui[t][k] += r;
666                    primal_sq += r * r;
667                }
668            }
669        }
670        let mut dz_sq = 0.0;
671        for t in 0..horizon {
672            for k in 0..2 {
673                let d = z[t][k] - z_prev[t][k];
674                dz_sq += d * d;
675            }
676        }
677        let primal = primal_sq.sqrt();
678        let dual = rho * (n as f64 * dz_sq).sqrt();
679        primal_residuals.push(primal);
680        dual_residuals.push(dual);
681        if primal < config.tol && dual < config.tol {
682            break;
683        }
684    }
685
686    Ok(HorizonConsensusReport {
687        center: z,
688        trajectories: x,
689        primal_residuals,
690        dual_residuals,
691        iterations,
692    })
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn unconstrained_recovers_closed_form_center() {
701        // With equal weights and no box, z* = mean(preferred - offset).
702        let agents = [
703            AgentSpec::new([1.0, 0.0], [1.0, 0.0]),
704            AgentSpec::new([0.0, 2.0], [-1.0, 0.0]),
705            AgentSpec::new([-1.0, -1.0], [0.0, 1.0]),
706        ];
707        let report = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
708        let mut zx = 0.0;
709        let mut zy = 0.0;
710        for a in &agents {
711            zx += (a.preferred[0] - a.offset[0]) / 3.0;
712            zy += (a.preferred[1] - a.offset[1]) / 3.0;
713        }
714        assert!(
715            (report.center[0] - zx).abs() < 1e-4,
716            "{} vs {}",
717            report.center[0],
718            zx
719        );
720        assert!(
721            (report.center[1] - zy).abs() < 1e-4,
722            "{} vs {}",
723            report.center[1],
724            zy
725        );
726        // Each agent sits at center + offset.
727        for (xi, a) in report.positions.iter().zip(&agents) {
728            assert!((xi[0] - (report.center[0] + a.offset[0])).abs() < 1e-3);
729            assert!((xi[1] - (report.center[1] + a.offset[1])).abs() < 1e-3);
730        }
731    }
732
733    #[test]
734    fn residuals_decrease_to_convergence() {
735        let agents = [
736            AgentSpec::new([2.0, 1.0], [1.0, 1.0]),
737            AgentSpec::new([-2.0, 1.0], [-1.0, 1.0]),
738            AgentSpec::new([-2.0, -1.0], [-1.0, -1.0]),
739            AgentSpec::new([2.0, -1.0], [1.0, -1.0]),
740        ];
741        let report = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
742        let first = report.primal_residuals[0];
743        let last = *report.primal_residuals.last().unwrap();
744        assert!(last < first, "primal should shrink: {first} -> {last}");
745        assert!(last < 1e-5, "should converge: {last}");
746    }
747
748    #[test]
749    fn box_constraint_is_respected() {
750        // A constrained agent cannot reach its formation slot, shifting consensus.
751        let agents = [
752            AgentSpec::new([0.0, 0.0], [1.0, 0.0]),
753            AgentSpec::new([0.0, 0.0], [-1.0, 0.0]).with_box([-0.4, -1.0], [0.4, 1.0]),
754        ];
755        let report = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
756        let x1 = report.positions[1];
757        assert!(
758            x1[0] >= -0.4 - 1e-9 && x1[0] <= 0.4 + 1e-9,
759            "x {x1:?} in box"
760        );
761    }
762
763    #[test]
764    fn is_deterministic() {
765        let agents = [
766            AgentSpec::new([1.0, 0.0], [0.5, 0.5]),
767            AgentSpec::new([0.0, 1.0], [-0.5, 0.5]),
768            AgentSpec::new([-1.0, 0.0], [-0.5, -0.5]),
769        ];
770        let a = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
771        let b = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
772        assert_eq!(a.positions, b.positions);
773        assert_eq!(a.center, b.center);
774        assert_eq!(a.primal_residuals, b.primal_residuals);
775    }
776
777    fn ring_edges(n: usize) -> Vec<(usize, usize)> {
778        (0..n).map(|i| (i, (i + 1) % n)).collect()
779    }
780
781    fn line_edges(n: usize) -> Vec<(usize, usize)> {
782        (0..n - 1).map(|i| (i, i + 1)).collect()
783    }
784
785    fn complete_edges(n: usize) -> Vec<(usize, usize)> {
786        let mut e = Vec::new();
787        for i in 0..n {
788            for j in (i + 1)..n {
789                e.push((i, j));
790            }
791        }
792        e
793    }
794
795    #[test]
796    fn graph_consensus_matches_centralized_center() {
797        let agents = [
798            AgentSpec::new([2.0, 1.0], [1.0, 1.0]),
799            AgentSpec::new([-2.0, 1.0], [-1.0, 1.0]),
800            AgentSpec::new([-2.0, -1.0], [-1.0, -1.0]),
801            AgentSpec::new([2.0, -1.0], [1.0, -1.0]),
802        ];
803        let config = AdmmConfig {
804            max_iters: 2000,
805            tol: 1e-8,
806            ..AdmmConfig::default()
807        };
808        let report = solve_graph_consensus(config, &agents, &ring_edges(4)).unwrap();
809        // Closed-form weighted-average consensus center.
810        let mut zx = 0.0;
811        let mut zy = 0.0;
812        for a in &agents {
813            zx += (a.preferred[0] - a.offset[0]) / 4.0;
814            zy += (a.preferred[1] - a.offset[1]) / 4.0;
815        }
816        assert!(
817            (report.center[0] - zx).abs() < 1e-3,
818            "{} vs {}",
819            report.center[0],
820            zx
821        );
822        assert!(
823            (report.center[1] - zy).abs() < 1e-3,
824            "{} vs {}",
825            report.center[1],
826            zy
827        );
828        assert!(*report.disagreement.last().unwrap() < 1e-6);
829    }
830
831    #[test]
832    fn connectivity_speeds_convergence() {
833        let agents: Vec<AgentSpec> = (0..6)
834            .map(|i| {
835                let f = i as f64;
836                AgentSpec::new([f - 2.5, (f * 1.3).sin()], [0.0, 0.0])
837            })
838            .collect();
839        let config = AdmmConfig {
840            max_iters: 5000,
841            tol: 1e-6,
842            ..AdmmConfig::default()
843        };
844        let line = solve_graph_consensus(config, &agents, &line_edges(6)).unwrap();
845        let complete = solve_graph_consensus(config, &agents, &complete_edges(6)).unwrap();
846        assert!(
847            complete.iterations < line.iterations,
848            "complete graph ({}) should converge faster than line ({})",
849            complete.iterations,
850            line.iterations
851        );
852    }
853
854    #[test]
855    fn graph_consensus_is_deterministic() {
856        let agents = [
857            AgentSpec::new([1.0, 0.0], [0.5, 0.5]),
858            AgentSpec::new([0.0, 1.0], [-0.5, 0.5]),
859            AgentSpec::new([-1.0, 0.0], [-0.5, -0.5]),
860        ];
861        let e = ring_edges(3);
862        let a = solve_graph_consensus(AdmmConfig::default(), &agents, &e).unwrap();
863        let b = solve_graph_consensus(AdmmConfig::default(), &agents, &e).unwrap();
864        assert_eq!(a.positions, b.positions);
865        assert_eq!(a.disagreement, b.disagreement);
866    }
867
868    #[test]
869    fn graph_consensus_rejects_bad_edges() {
870        let agents = [AgentSpec::new([0.0, 0.0], [0.0, 0.0])];
871        assert!(solve_graph_consensus(AdmmConfig::default(), &agents, &[(0, 5)]).is_err());
872        assert!(solve_graph_consensus(AdmmConfig::default(), &agents, &[(0, 0)]).is_err());
873    }
874
875    #[test]
876    fn horizon_one_step_no_smoothing_matches_static() {
877        // With smooth_weight = 0 and a one-step horizon the trajectory consensus
878        // collapses to the static centralized consensus.
879        let statics = [
880            AgentSpec::new([2.0, 1.0], [1.0, 1.0]),
881            AgentSpec::new([-2.0, 1.0], [-1.0, 1.0]),
882            AgentSpec::new([-2.0, -1.0], [-1.0, -1.0]),
883        ];
884        let trajs: Vec<AgentTrajectory> = statics
885            .iter()
886            .map(|s| AgentTrajectory::new(vec![s.preferred], s.offset))
887            .collect();
888        let stat = solve_formation_consensus(AdmmConfig::default(), &statics).unwrap();
889        let horiz = solve_horizon_consensus(AdmmConfig::default(), &trajs, 0.0, None).unwrap();
890        assert!((horiz.center[0][0] - stat.center[0]).abs() < 1e-6);
891        assert!((horiz.center[0][1] - stat.center[1]).abs() < 1e-6);
892    }
893
894    #[test]
895    #[allow(clippy::needless_range_loop)]
896    fn smoothing_reduces_center_acceleration() {
897        // A jagged set of references; smoothing should flatten the center path.
898        let h = 16;
899        let refs: Vec<[f64; 2]> = (0..h)
900            .map(|t| {
901                let f = t as f64;
902                [f * 0.3, if t % 2 == 0 { 1.0 } else { -1.0 }]
903            })
904            .collect();
905        let agents = vec![AgentTrajectory::new(refs, [0.0, 0.0])];
906        let config = AdmmConfig {
907            max_iters: 2000,
908            tol: 1e-9,
909            ..AdmmConfig::default()
910        };
911        let accel = |r: &HorizonConsensusReport| {
912            let z = &r.center;
913            let mut s = 0.0;
914            for t in 1..z.len() - 1 {
915                for k in 0..2 {
916                    let a = z[t + 1][k] - 2.0 * z[t][k] + z[t - 1][k];
917                    s += a * a;
918                }
919            }
920            s
921        };
922        let stiff = solve_horizon_consensus(config, &agents, 0.0, None).unwrap();
923        let smooth = solve_horizon_consensus(config, &agents, 20.0, None).unwrap();
924        assert!(
925            accel(&smooth) < 0.25 * accel(&stiff),
926            "smoothing should cut acceleration: {} vs {}",
927            accel(&smooth),
928            accel(&stiff)
929        );
930    }
931
932    #[test]
933    fn anchor_fixes_first_center_step() {
934        let h = 8;
935        let refs: Vec<[f64; 2]> = (0..h).map(|t| [t as f64, 0.0]).collect();
936        let agents = vec![AgentTrajectory::new(refs, [0.0, 0.0])];
937        let anchor = [-5.0, 2.0];
938        let report = solve_horizon_consensus(
939            AdmmConfig {
940                max_iters: 500,
941                ..AdmmConfig::default()
942            },
943            &agents,
944            5.0,
945            Some(anchor),
946        )
947        .unwrap();
948        assert!((report.center[0][0] - anchor[0]).abs() < 1e-9);
949        assert!((report.center[0][1] - anchor[1]).abs() < 1e-9);
950    }
951
952    #[test]
953    fn horizon_box_is_respected() {
954        let h = 6;
955        let refs: Vec<[f64; 2]> = (0..h).map(|t| [t as f64, 3.0]).collect();
956        let agents = vec![
957            AgentTrajectory::new(refs.clone(), [0.0, 0.5]),
958            AgentTrajectory::new(refs, [0.0, -0.5]).with_box([-100.0, -1.0], [100.0, 1.0]),
959        ];
960        let report = solve_horizon_consensus(AdmmConfig::default(), &agents, 1.0, None).unwrap();
961        for p in &report.trajectories[1] {
962            assert!(p[1] >= -1.0 - 1e-9 && p[1] <= 1.0 + 1e-9, "y {p:?} in box");
963        }
964    }
965
966    #[test]
967    fn horizon_consensus_is_deterministic() {
968        let refs: Vec<[f64; 2]> = (0..10)
969            .map(|t| [t as f64 * 0.5, (t as f64).sin()])
970            .collect();
971        let agents = vec![
972            AgentTrajectory::new(refs.clone(), [0.5, 0.5]),
973            AgentTrajectory::new(refs, [-0.5, -0.5]),
974        ];
975        let a =
976            solve_horizon_consensus(AdmmConfig::default(), &agents, 3.0, Some([0.0, 0.0])).unwrap();
977        let b =
978            solve_horizon_consensus(AdmmConfig::default(), &agents, 3.0, Some([0.0, 0.0])).unwrap();
979        assert_eq!(a.center, b.center);
980        assert_eq!(a.trajectories, b.trajectories);
981        assert_eq!(a.primal_residuals, b.primal_residuals);
982    }
983
984    #[test]
985    fn horizon_rejects_invalid_input() {
986        assert!(solve_horizon_consensus(AdmmConfig::default(), &[], 1.0, None).is_err());
987        let a = vec![AgentTrajectory::new(vec![[0.0, 0.0]], [0.0, 0.0])];
988        // Negative smoothing.
989        assert!(solve_horizon_consensus(AdmmConfig::default(), &a, -1.0, None).is_err());
990        // Mismatched trajectory lengths.
991        let mixed = vec![
992            AgentTrajectory::new(vec![[0.0, 0.0], [1.0, 0.0]], [0.0, 0.0]),
993            AgentTrajectory::new(vec![[0.0, 0.0]], [0.0, 0.0]),
994        ];
995        assert!(solve_horizon_consensus(AdmmConfig::default(), &mixed, 1.0, None).is_err());
996        // Empty trajectory.
997        let empty = vec![AgentTrajectory::new(vec![], [0.0, 0.0])];
998        assert!(solve_horizon_consensus(AdmmConfig::default(), &empty, 1.0, None).is_err());
999    }
1000
1001    #[test]
1002    fn rejects_invalid_input() {
1003        assert!(solve_formation_consensus(AdmmConfig::default(), &[]).is_err());
1004        let bad_rho = AdmmConfig {
1005            rho: 0.0,
1006            ..AdmmConfig::default()
1007        };
1008        assert!(
1009            solve_formation_consensus(bad_rho, &[AgentSpec::new([0.0, 0.0], [0.0, 0.0])]).is_err()
1010        );
1011    }
1012}