Skip to main content

spectral_fleet/
embedding.rs

1//! Spectral embedding: map agents to low-dimensional space.
2//!
3//! Uses the smallest eigenvectors of the graph Laplacian to embed agents
4//! in R^k such that distance in embedded space approximates communication cost.
5//! Supports 2D visualization using a t-SNE-like approach.
6
7use crate::fleet_graph::FleetGraph;
8use crate::laplacian::{combinatorial_laplacian, eigen_decompose};
9
10/// A spectral embedding of the fleet graph.
11#[derive(Debug, Clone)]
12pub struct Embedding {
13    /// Number of dimensions.
14    pub dimensions: usize,
15    /// Coordinates of each agent in R^dimensions.
16    pub coordinates: Vec<Vec<f64>>,
17    /// Reconstruction error (how well the embedding preserves distances).
18    pub reconstruction_error: f64,
19}
20
21impl Embedding {
22    pub fn new(dimensions: usize, coordinates: Vec<Vec<f64>>) -> Self {
23        Self {
24            dimensions,
25            coordinates,
26            reconstruction_error: 0.0,
27        }
28    }
29
30    /// Compute pairwise distances in the embedded space.
31    pub fn pairwise_distances(&self) -> Vec<Vec<f64>> {
32        let n = self.coordinates.len();
33        let mut dists = vec![vec![0.0; n]; n];
34        for i in 0..n {
35            for j in (i + 1)..n {
36                let d: f64 = self.coordinates[i]
37                    .iter()
38                    .zip(self.coordinates[j].iter())
39                    .map(|(&a, &b)| (a - b).powi(2))
40                    .sum::<f64>()
41                    .sqrt();
42                dists[i][j] = d;
43                dists[j][i] = d;
44            }
45        }
46        dists
47    }
48}
49
50/// Compute spectral embedding using the k smallest eigenvectors of the Laplacian.
51pub fn spectral_embedding(graph: &FleetGraph, dimensions: usize) -> Embedding {
52    let n = graph.node_count();
53    if n == 0 {
54        return Embedding::new(dimensions, vec![]);
55    }
56
57    let lap = combinatorial_laplacian(graph);
58    let (eigenvalues, eigenvectors) = eigen_decompose(&lap, 1000, 1e-10);
59
60    // Skip the first eigenvector (constant vector for connected graph)
61    // Use eigenvectors 1..dimensions+1
62    let num_vecs = dimensions.min(eigenvalues.len().saturating_sub(1)).max(1);
63
64    let mut coordinates = Vec::with_capacity(n);
65    for i in 0..n {
66        let mut point = Vec::with_capacity(num_vecs);
67        for j in 1..=num_vecs {
68            if j < eigenvectors.len() {
69                point.push(eigenvectors[j][i]);
70            } else {
71                point.push(0.0);
72            }
73        }
74        coordinates.push(point);
75    }
76
77    // Compute reconstruction error
78    let embedding = Embedding::new(num_vecs, coordinates);
79    let error = compute_reconstruction_error(graph, &embedding);
80
81    Embedding {
82        dimensions: num_vecs,
83        coordinates: embedding.coordinates,
84        reconstruction_error: error,
85    }
86}
87
88/// Compute how well the embedding preserves graph distances.
89/// Lower is better. Uses stress function.
90fn compute_reconstruction_error(graph: &FleetGraph, embedding: &Embedding) -> f64 {
91    let n = graph.node_count();
92    if n < 2 {
93        return 0.0;
94    }
95
96    let adj = graph.undirected_adjacency();
97    let embed_dists = embedding.pairwise_distances();
98
99    // Graph shortest path distances
100    let mut graph_dists = vec![vec![f64::INFINITY; n]; n];
101    for s in 0..n {
102        graph_dists[s][s] = 0.0;
103        let mut queue = std::collections::VecDeque::new();
104        queue.push_back(s);
105        while let Some(u) = queue.pop_front() {
106            for v in 0..n {
107                if adj[u][v] > 0.0 && graph_dists[s][v] == f64::INFINITY {
108                    graph_dists[s][v] = graph_dists[s][u] + 1.0;
109                    queue.push_back(v);
110                }
111            }
112        }
113    }
114
115    // Stress: sum of (embed_dist - graph_dist)^2 / graph_dist^2
116    let mut stress = 0.0;
117    let mut count = 0;
118    for i in 0..n {
119        for j in (i + 1)..n {
120            let gd = graph_dists[i][j];
121            if gd.is_finite() && gd > 0.0 {
122                let ed = embed_dists[i][j];
123                stress += ((ed - gd) / gd).powi(2);
124                count += 1;
125            }
126        }
127    }
128    if count > 0 {
129        stress / count as f64
130    } else {
131        0.0
132    }
133}
134
135/// t-SNE-style embedding for 2D visualization.
136/// Uses a simplified Barnes-Hut t-SNE approach.
137pub fn tsne_embedding(
138    graph: &FleetGraph,
139    perplexity: f64,
140    iterations: usize,
141) -> Embedding {
142    let n = graph.node_count();
143    if n == 0 {
144        return Embedding::new(2, vec![]);
145    }
146
147    // Start from spectral embedding
148    let initial = spectral_embedding(graph, 2);
149
150    let adj = graph.undirected_adjacency();
151
152    // Compute graph shortest path distances as the "high-dimensional" distances
153    let mut high_dists = vec![vec![f64::INFINITY; n]; n];
154    for s in 0..n {
155        high_dists[s][s] = 0.0;
156        let mut queue = std::collections::VecDeque::new();
157        queue.push_back(s);
158        while let Some(u) = queue.pop_front() {
159            for v in 0..n {
160                if adj[u][v] > 0.0 && high_dists[s][v] == f64::INFINITY {
161                    high_dists[s][v] = high_dists[s][u] + 1.0;
162                    queue.push_back(v);
163                }
164            }
165        }
166    }
167
168    // Convert to similarity (Gaussian kernel)
169    let sigma = perplexity;
170    let mut p = vec![vec![0.0; n]; n];
171    for i in 0..n {
172        for j in 0..n {
173            if i != j && high_dists[i][j].is_finite() {
174                p[i][j] = (-high_dists[i][j].powi(2) / (2.0 * sigma * sigma)).exp();
175            }
176        }
177        // Normalize
178        let row_sum: f64 = p[i].iter().sum();
179        if row_sum > 0.0 {
180            for j in 0..n {
181                p[i][j] /= row_sum;
182            }
183        }
184    }
185
186    // Symmetrize
187    for i in 0..n {
188        for j in 0..n {
189            p[i][j] = (p[i][j] + p[j][i]) / (2.0 * n as f64);
190        }
191    }
192
193    // Initialize from spectral embedding
194    let mut y: Vec<Vec<f64>> = initial.coordinates.clone();
195    // Pad to 2D if needed
196    for point in &mut y {
197        while point.len() < 2 {
198            point.push(0.0);
199        }
200        point.truncate(2);
201    }
202
203    // Gradient descent
204    let mut gains = vec![vec![1.0_f64; 2]; n];
205    let mut y_vel = vec![vec![0.0_f64; 2]; n];
206    let learning_rate = 100.0;
207    let momentum = 0.8;
208
209    for _ in 0..iterations {
210        // Compute low-dimensional similarities (t-distribution)
211        let mut q = vec![vec![0.0; n]; n];
212        let mut q_sum = 0.0;
213        for i in 0..n {
214            for j in (i + 1)..n {
215                let dist_sq: f64 = y[i]
216                    .iter()
217                    .zip(y[j].iter())
218                    .map(|(&a, &b)| (a - b).powi(2))
219                    .sum();
220                let val = 1.0 / (1.0 + dist_sq);
221                q[i][j] = val;
222                q[j][i] = val;
223                q_sum += 2.0 * val;
224            }
225        }
226        if q_sum > 0.0 {
227            for i in 0..n {
228                for j in 0..n {
229                    q[i][j] /= q_sum;
230                }
231            }
232        }
233
234        // Compute gradients
235        let mut gradients = vec![vec![0.0; 2]; n];
236        for i in 0..n {
237            for j in 0..n {
238                if i == j {
239                    continue;
240                }
241                let mult = 4.0 * (p[i][j] - q[i][j]);
242                let dist_sq: f64 = y[i]
243                    .iter()
244                    .zip(y[j].iter())
245                    .map(|(&a, &b)| (a - b).powi(2))
246                    .sum();
247                let factor = mult / (1.0 + dist_sq);
248                for d in 0..2 {
249                    gradients[i][d] += factor * (y[i][d] - y[j][d]);
250                }
251            }
252        }
253
254        // Update positions
255        for i in 0..n {
256            for d in 0..2 {
257                let grad_sign = gradients[i][d].signum();
258                let vel_sign = y_vel[i][d].signum();
259                gains[i][d] = if grad_sign != vel_sign {
260                    gains[i][d] + 0.2_f64
261                } else {
262                    gains[i][d] * 0.8_f64
263                };
264                gains[i][d] = f64::max(gains[i][d], 0.01_f64);
265                y_vel[i][d] = momentum * y_vel[i][d] - learning_rate * gains[i][d] * gradients[i][d];
266                y[i][d] += y_vel[i][d];
267            }
268        }
269    }
270
271    // Center the embedding
272    let mut center = vec![0.0; 2];
273    for point in &y {
274        for d in 0..2 {
275            center[d] += point[d];
276        }
277    }
278    for d in 0..2 {
279        center[d] /= n as f64;
280    }
281    for point in &mut y {
282        for d in 0..2 {
283            point[d] -= center[d];
284        }
285    }
286
287    let mut embedding = Embedding::new(2, y);
288    embedding.reconstruction_error = compute_reconstruction_error(graph, &embedding);
289    embedding
290}
291
292/// Render a 2D embedding as an ASCII art string.
293pub fn render_ascii(embedding: &Embedding, width: usize, height: usize) -> String {
294    if embedding.coordinates.is_empty() {
295        return String::new();
296    }
297
298    let coords = &embedding.coordinates;
299    let dims = coords[0].len();
300    if dims < 2 {
301        return String::new();
302    }
303
304    // Find bounds
305    let (mut min_x, mut max_x) = (f64::MAX, f64::MIN);
306    let (mut min_y, mut max_y) = (f64::MAX, f64::MIN);
307    for p in coords {
308        min_x = min_x.min(p[0]);
309        max_x = max_x.max(p[0]);
310        min_y = min_y.min(p[1]);
311        max_y = max_y.max(p[1]);
312    }
313
314    let range_x = (max_x - min_x).max(1e-10);
315    let range_y = (max_y - min_y).max(1e-10);
316
317    let mut canvas = vec![vec![' '; width]; height];
318
319    for (idx, p) in coords.iter().enumerate() {
320        let x = ((p[0] - min_x) / range_x * (width - 1) as f64).round() as usize;
321        let y = ((p[1] - min_y) / range_y * (height - 1) as f64).round() as usize;
322        let x = x.min(width - 1);
323        let y = y.min(height - 1);
324        let ch = if idx < 26 {
325            (b'A' + idx as u8) as char
326        } else {
327            '#'
328        };
329        canvas[height - 1 - y][x] = ch;
330    }
331
332    canvas
333        .iter()
334        .map(|row| row.iter().collect::<String>())
335        .collect::<Vec<_>>()
336        .join("\n")
337}