Skip to main content

clawft_kernel/
quantum_register.rs

1//! Graph → 2D atom register layout, shared across quantum backends.
2//!
3//! EXPERIMENTAL (0.6.x). Both Pasqal Fresnel and QuEra Aquila consume a list
4//! of named atoms at 2D coordinates in micrometers. This module produces a
5//! deterministic layout from an adjacency list so the same graph maps to the
6//! same register across backends and across runs.
7//!
8//! ## Layout backends (WEFT-129)
9//!
10//! - **Spectral embedding** (default): places atoms using the 2nd and 3rd
11//!   eigenvectors of the (combinatorial) graph Laplacian. Preserves
12//!   Laplacian structure, which is the same operator used as the quantum
13//!   Hamiltonian in [`crate::quantum_state`]. Dense Jacobi eigensolve is
14//!   O(n³) — fine for device limits (≤100 atoms). Pure Rust; no Wasmtime.
15//! - **Force-directed** (Fruchterman–Reingold): retained as an explicit
16//!   option and as an automatic fallback when spectral positions collapse
17//!   (e.g. highly symmetric or degenerate graphs).
18//!
19//! Note: the 0.7.0 audit row titled this "Wasmtime backend for
20//! spectral_embedding". That was a misnomer — spectral embedding is
21//! classical linear algebra on n≤100, not a WASM sandbox concern. The
22//! production surface is this pure-Rust path.
23
24use crate::quantum_backend::QuantumError;
25
26/// Device-independent register constraints used during layout.
27#[derive(Debug, Clone, Copy)]
28pub struct RegisterConstraints {
29    /// Minimum inter-atom distance in micrometers.
30    pub min_distance_um: f64,
31    /// Maximum extent of the trap area, per axis, in micrometers.
32    pub max_extent_um: f64,
33    /// Maximum atoms supported by the target device.
34    pub max_atoms: usize,
35}
36
37impl RegisterConstraints {
38    /// Defaults that satisfy both Fresnel (~4um min spacing) and Aquila.
39    pub fn neutral_atom_default() -> Self {
40        Self {
41            min_distance_um: 4.0,
42            max_extent_um: 75.0,
43            max_atoms: 100,
44        }
45    }
46}
47
48/// Algorithm used to map a graph adjacency list to 2D atom positions.
49///
50/// Default is [`LayoutMethod::Spectral`] (WEFT-129). Force-directed remains
51/// available for A/B and as spectral fallback.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum LayoutMethod {
54    /// Laplacian spectral embedding (eigenvectors 2 and 3). Default.
55    #[default]
56    Spectral,
57    /// Classical force-directed (Fruchterman–Reingold).
58    ForceDirected,
59}
60
61/// Build a named 2D register from a graph adjacency list.
62///
63/// Uses [`LayoutMethod::Spectral`] by default. See
64/// [`build_register_with`] to pick the algorithm explicitly.
65///
66/// `adjacency[i]` is the list of `(neighbor_index, weight)` pairs for node i.
67/// Returned positions are in micrometers, scaled to fit within the constraints.
68///
69/// The layout is deterministic for a given adjacency input.
70pub fn build_register(
71    adjacency: &[Vec<(usize, f64)>],
72    constraints: RegisterConstraints,
73) -> Result<Vec<(String, [f64; 2])>, QuantumError> {
74    build_register_with(adjacency, constraints, LayoutMethod::Spectral)
75}
76
77/// Build a named 2D register with an explicit [`LayoutMethod`].
78pub fn build_register_with(
79    adjacency: &[Vec<(usize, f64)>],
80    constraints: RegisterConstraints,
81    method: LayoutMethod,
82) -> Result<Vec<(String, [f64; 2])>, QuantumError> {
83    let n = adjacency.len();
84    if n == 0 {
85        return Ok(Vec::new());
86    }
87    if n > constraints.max_atoms {
88        return Err(QuantumError::GraphTooLarge {
89            nodes: n,
90            max: constraints.max_atoms,
91        });
92    }
93
94    let positions = match method {
95        LayoutMethod::Spectral => {
96            match spectral_embedding_layout(adjacency) {
97                Some(pos) if layout_min_pairwise(&pos) > 1e-9 => pos,
98                // Degenerate / collapsed spectral layout → force-directed.
99                _ => force_directed_layout(adjacency, 200),
100            }
101        }
102        LayoutMethod::ForceDirected => force_directed_layout(adjacency, 200),
103    };
104
105    let scaled = scale_to_constraints(&positions, constraints)?;
106
107    Ok(scaled
108        .into_iter()
109        .enumerate()
110        .map(|(i, p)| (format!("q{}", i), p))
111        .collect())
112}
113
114// ---------------------------------------------------------------------------
115// Spectral embedding (WEFT-129)
116// ---------------------------------------------------------------------------
117
118/// Place nodes using the 2nd and 3rd eigenvectors of the combinatorial
119/// graph Laplacian L = D − A (symmetric, undirected view of the input).
120///
121/// Returns `None` when the spectral coordinates are unusable (n < 2 after
122/// filtering, or eigensolve produced non-finite values).
123fn spectral_embedding_layout(adjacency: &[Vec<(usize, f64)>]) -> Option<Vec<[f64; 2]>> {
124    let n = adjacency.len();
125    if n == 0 {
126        return Some(Vec::new());
127    }
128    if n == 1 {
129        return Some(vec![[0.0, 0.0]]);
130    }
131
132    // Symmetric weighted adjacency (max of either direction keeps weights
133    // non-negative and deterministic).
134    let mut a = vec![vec![0.0f64; n]; n];
135    for (i, edges) in adjacency.iter().enumerate() {
136        for &(j, w) in edges {
137            if j >= n || i == j {
138                continue;
139            }
140            let ww = w.max(0.0);
141            if ww > a[i][j] {
142                a[i][j] = ww;
143                a[j][i] = ww;
144            }
145        }
146    }
147
148    // Combinatorial Laplacian L = D − A.
149    let mut l = vec![vec![0.0f64; n]; n];
150    for i in 0..n {
151        let mut deg = 0.0f64;
152        for j in 0..n {
153            if i != j {
154                deg += a[i][j];
155                l[i][j] = -a[i][j];
156            }
157        }
158        l[i][i] = deg;
159    }
160
161    let (evals, evecs) = dense_jacobi_eigen(&l, n, 200 * n.max(1));
162
163    // Sort eigenpairs by eigenvalue ascending (λ1 ≈ 0 for connected graphs).
164    let mut order: Vec<usize> = (0..n).collect();
165    order.sort_by(|&i, &j| {
166        evals[i]
167            .partial_cmp(&evals[j])
168            .unwrap_or(std::cmp::Ordering::Equal)
169    });
170
171    // Pick the first two eigenvectors with λ above the numerical null-space
172    // floor. For a connected graph these are the Fiedler vector (x) and the
173    // next mode (y). For disconnected graphs we still take the two smallest
174    // non-null modes so components separate spatially.
175    const NULL_FLOOR: f64 = 1e-8;
176    let mut axes: Vec<usize> = Vec::with_capacity(2);
177    for &idx in &order {
178        if evals[idx] > NULL_FLOOR {
179            axes.push(idx);
180            if axes.len() == 2 {
181                break;
182            }
183        }
184    }
185
186    // Pathological: only one non-null mode (e.g. n=2). Use it for x and a
187    // deterministic orthogonal for y.
188    if axes.is_empty() {
189        return None;
190    }
191
192    let mut pos = vec![[0.0f64; 2]; n];
193    let vx = &evecs[axes[0]];
194    for i in 0..n {
195        pos[i][0] = vx[i];
196    }
197
198    if axes.len() >= 2 {
199        let vy = &evecs[axes[1]];
200        for i in 0..n {
201            pos[i][1] = vy[i];
202        }
203    } else {
204        // Build a deterministic vector orthogonal to vx in R^n, project, use as y.
205        let mut y: Vec<f64> = (0..n)
206            .map(|i| ((i as f64) + 0.5) - (n as f64) / 2.0)
207            .collect();
208        let dot: f64 = y.iter().zip(vx.iter()).map(|(a, b)| a * b).sum();
209        let norm_x2: f64 = vx.iter().map(|x| x * x).sum::<f64>().max(1e-18);
210        for i in 0..n {
211            y[i] -= dot / norm_x2 * vx[i];
212        }
213        let norm_y: f64 = y.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-18);
214        for i in 0..n {
215            pos[i][1] = y[i] / norm_y;
216        }
217    }
218
219    // Sign-canonicalize each axis so the layout is stable across platforms
220    // (eigenvector sign is free). Make the first non-near-zero component positive.
221    for axis in 0..2 {
222        let mut flip = false;
223        for p in pos.iter().take(n) {
224            if p[axis].abs() > 1e-12 {
225                flip = p[axis] < 0.0;
226                break;
227            }
228        }
229        if flip {
230            for p in pos.iter_mut().take(n) {
231                p[axis] = -p[axis];
232            }
233        }
234    }
235
236    // Reject non-finite coordinates.
237    if pos.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) {
238        return None;
239    }
240
241    Some(pos)
242}
243
244/// Dense Jacobi eigensolve for a symmetric matrix (local copy of the
245/// algorithm used in `causal.rs` so this module stays free of CausalGraph).
246///
247/// Returns `(eigenvalues, eigenvectors)` where `eigenvectors[j]` is the
248/// j-th eigenvector (length m).
249// Index loops over a dense symmetric matrix touch a[i][j], a[r][p] and a[p][r] together; iterator forms would not read as the algorithm.
250#[allow(clippy::needless_range_loop)]
251fn dense_jacobi_eigen(mat: &[Vec<f64>], m: usize, max_iter: usize) -> (Vec<f64>, Vec<Vec<f64>>) {
252    if m == 0 {
253        return (Vec::new(), Vec::new());
254    }
255    if m == 1 {
256        return (vec![mat[0][0]], vec![vec![1.0]]);
257    }
258
259    let mut a = mat.to_vec();
260    let mut v = vec![vec![0.0f64; m]; m];
261    for (i, row) in v.iter_mut().enumerate() {
262        row[i] = 1.0;
263    }
264
265    for _ in 0..max_iter {
266        let mut max_off = 0.0f64;
267        let mut p = 0usize;
268        let mut q = 1usize;
269        for i in 0..m {
270            for j in (i + 1)..m {
271                if a[i][j].abs() > max_off {
272                    max_off = a[i][j].abs();
273                    p = i;
274                    q = j;
275                }
276            }
277        }
278
279        if max_off < 1e-15 {
280            break;
281        }
282
283        let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
284        let t = if theta == 0.0 || !theta.is_finite() {
285            1.0
286        } else {
287            theta.signum() / (theta.abs() + (1.0 + theta * theta).sqrt())
288        };
289        let c = 1.0 / (1.0 + t * t).sqrt();
290        let s = t * c;
291
292        let app = a[p][p];
293        let aqq = a[q][q];
294        let apq = a[p][q];
295        a[p][p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
296        a[q][q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
297        a[p][q] = 0.0;
298        a[q][p] = 0.0;
299
300        for r in 0..m {
301            if r != p && r != q {
302                let arp = a[r][p];
303                let arq = a[r][q];
304                a[r][p] = c * arp - s * arq;
305                a[p][r] = a[r][p];
306                a[r][q] = s * arp + c * arq;
307                a[q][r] = a[r][q];
308            }
309        }
310
311        for row in v.iter_mut() {
312            let vp = row[p];
313            let vq = row[q];
314            row[p] = c * vp - s * vq;
315            row[q] = s * vp + c * vq;
316        }
317    }
318
319    let eigenvalues: Vec<f64> = (0..m).map(|i| a[i][i]).collect();
320    let eigenvectors: Vec<Vec<f64>> = (0..m).map(|j| (0..m).map(|i| v[i][j]).collect()).collect();
321    (eigenvalues, eigenvectors)
322}
323
324fn layout_min_pairwise(positions: &[[f64; 2]]) -> f64 {
325    if positions.len() < 2 {
326        return f64::INFINITY;
327    }
328    let mut min_d = f64::INFINITY;
329    for i in 0..positions.len() {
330        for j in (i + 1)..positions.len() {
331            let dx = positions[i][0] - positions[j][0];
332            let dy = positions[i][1] - positions[j][1];
333            let d = (dx * dx + dy * dy).sqrt();
334            if d < min_d {
335                min_d = d;
336            }
337        }
338    }
339    min_d
340}
341
342// ---------------------------------------------------------------------------
343// Force-directed (Fruchterman–Reingold)
344// ---------------------------------------------------------------------------
345
346fn force_directed_layout(adjacency: &[Vec<(usize, f64)>], iterations: u32) -> Vec<[f64; 2]> {
347    let n = adjacency.len();
348    // Deterministic initial positions on a unit circle.
349    let mut pos: Vec<[f64; 2]> = (0..n)
350        .map(|i| {
351            let theta = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64);
352            [theta.cos(), theta.sin()]
353        })
354        .collect();
355
356    let k = (1.0 / (n as f64).max(1.0)).sqrt();
357    let k2 = k * k;
358
359    for iter in 0..iterations {
360        let temperature = 0.1 * (1.0 - iter as f64 / iterations as f64).max(0.01);
361        let mut disp = vec![[0.0_f64; 2]; n];
362
363        // Repulsion (all pairs).
364        for i in 0..n {
365            for j in 0..n {
366                if i == j {
367                    continue;
368                }
369                let dx = pos[i][0] - pos[j][0];
370                let dy = pos[i][1] - pos[j][1];
371                let dist2 = (dx * dx + dy * dy).max(1e-9);
372                let force = k2 / dist2;
373                disp[i][0] += dx * force;
374                disp[i][1] += dy * force;
375            }
376        }
377
378        // Attraction along edges, weighted.
379        for (i, edges) in adjacency.iter().enumerate() {
380            for &(j, w) in edges {
381                if j >= n {
382                    continue;
383                }
384                let dx = pos[i][0] - pos[j][0];
385                let dy = pos[i][1] - pos[j][1];
386                let dist = (dx * dx + dy * dy).sqrt().max(1e-9);
387                let force = w * dist / k;
388                disp[i][0] -= dx / dist * force;
389                disp[i][1] -= dy / dist * force;
390            }
391        }
392
393        for i in 0..n {
394            let mag = (disp[i][0].powi(2) + disp[i][1].powi(2)).sqrt().max(1e-9);
395            let step = mag.min(temperature);
396            pos[i][0] += disp[i][0] / mag * step;
397            pos[i][1] += disp[i][1] / mag * step;
398        }
399    }
400    pos
401}
402
403fn scale_to_constraints(
404    positions: &[[f64; 2]],
405    c: RegisterConstraints,
406) -> Result<Vec<[f64; 2]>, QuantumError> {
407    if positions.is_empty() {
408        return Ok(Vec::new());
409    }
410
411    // Find minimum pairwise distance in the raw layout.
412    let mut min_d = f64::INFINITY;
413    for i in 0..positions.len() {
414        for j in (i + 1)..positions.len() {
415            let dx = positions[i][0] - positions[j][0];
416            let dy = positions[i][1] - positions[j][1];
417            let d = (dx * dx + dy * dy).sqrt();
418            if d < min_d {
419                min_d = d;
420            }
421        }
422    }
423
424    if positions.len() == 1 {
425        return Ok(vec![[0.0, 0.0]]);
426    }
427
428    if !min_d.is_finite() || min_d <= 0.0 {
429        return Err(QuantumError::InvalidRegister(
430            "degenerate layout: atoms collapsed to same point".into(),
431        ));
432    }
433
434    // Scale so min_d -> c.min_distance_um.
435    let scale = c.min_distance_um / min_d;
436    let mut scaled: Vec<[f64; 2]> = positions
437        .iter()
438        .map(|p| [p[0] * scale, p[1] * scale])
439        .collect();
440
441    // Check extent.
442    let (mut xmin, mut xmax, mut ymin, mut ymax) = (
443        f64::INFINITY,
444        f64::NEG_INFINITY,
445        f64::INFINITY,
446        f64::NEG_INFINITY,
447    );
448    for p in &scaled {
449        xmin = xmin.min(p[0]);
450        xmax = xmax.max(p[0]);
451        ymin = ymin.min(p[1]);
452        ymax = ymax.max(p[1]);
453    }
454    let extent = (xmax - xmin).max(ymax - ymin);
455    if extent > c.max_extent_um {
456        return Err(QuantumError::InvalidRegister(format!(
457            "register extent {:.1}um exceeds device max {:.1}um",
458            extent, c.max_extent_um
459        )));
460    }
461
462    // Recenter on origin.
463    let cx = (xmin + xmax) / 2.0;
464    let cy = (ymin + ymax) / 2.0;
465    for p in &mut scaled {
466        p[0] -= cx;
467        p[1] -= cy;
468    }
469    Ok(scaled)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn empty_graph_returns_empty_register() {
478        let r = build_register(&[], RegisterConstraints::neutral_atom_default()).unwrap();
479        assert!(r.is_empty());
480    }
481
482    #[test]
483    fn single_node_places_at_origin() {
484        let adj = vec![vec![]];
485        let r = build_register(&adj, RegisterConstraints::neutral_atom_default()).unwrap();
486        assert_eq!(r.len(), 1);
487        assert_eq!(r[0].0, "q0");
488        assert_eq!(r[0].1, [0.0, 0.0]);
489    }
490
491    #[test]
492    fn too_large_graph_errors() {
493        let adj: Vec<Vec<(usize, f64)>> = (0..5).map(|_| vec![]).collect();
494        let constraints = RegisterConstraints {
495            min_distance_um: 4.0,
496            max_extent_um: 75.0,
497            max_atoms: 3,
498        };
499        let err = build_register(&adj, constraints).unwrap_err();
500        matches!(err, QuantumError::GraphTooLarge { nodes: 5, max: 3 });
501    }
502
503    #[test]
504    fn small_graph_respects_min_distance() {
505        let adj = vec![
506            vec![(1, 1.0), (2, 1.0)],
507            vec![(0, 1.0), (2, 1.0)],
508            vec![(0, 1.0), (1, 1.0)],
509        ];
510        let c = RegisterConstraints::neutral_atom_default();
511        let r = build_register(&adj, c).unwrap();
512        assert_eq!(r.len(), 3);
513        for i in 0..3 {
514            for j in (i + 1)..3 {
515                let dx = r[i].1[0] - r[j].1[0];
516                let dy = r[i].1[1] - r[j].1[1];
517                let d = (dx * dx + dy * dy).sqrt();
518                assert!(
519                    d >= c.min_distance_um - 1e-6,
520                    "atoms {} and {} too close: {}",
521                    i,
522                    j,
523                    d
524                );
525            }
526        }
527    }
528
529    #[test]
530    fn layout_is_deterministic() {
531        let adj = vec![vec![(1, 1.0)], vec![(0, 1.0), (2, 1.0)], vec![(1, 1.0)]];
532        let c = RegisterConstraints::neutral_atom_default();
533        let r1 = build_register(&adj, c).unwrap();
534        let r2 = build_register(&adj, c).unwrap();
535        assert_eq!(r1, r2);
536    }
537
538    #[test]
539    fn spectral_path_graph_places_endpoints_farther() {
540        // Path 0—1—2—3: spectral x-coordinate should order nodes along the path.
541        let adj = vec![
542            vec![(1, 1.0)],
543            vec![(0, 1.0), (2, 1.0)],
544            vec![(1, 1.0), (3, 1.0)],
545            vec![(2, 1.0)],
546        ];
547        let c = RegisterConstraints::neutral_atom_default();
548        let r = build_register_with(&adj, c, LayoutMethod::Spectral).unwrap();
549        assert_eq!(r.len(), 4);
550
551        // Endpoints (0,3) should be at least as far apart as any consecutive pair.
552        let dist = |i: usize, j: usize| {
553            let dx = r[i].1[0] - r[j].1[0];
554            let dy = r[i].1[1] - r[j].1[1];
555            (dx * dx + dy * dy).sqrt()
556        };
557        let end = dist(0, 3);
558        let mid = dist(1, 2);
559        assert!(
560            end + 1e-9 >= mid,
561            "path endpoints should not be closer than middle edge: end={end} mid={mid}"
562        );
563
564        // Min distance still holds.
565        for i in 0..4 {
566            for j in (i + 1)..4 {
567                assert!(dist(i, j) >= c.min_distance_um - 1e-6);
568            }
569        }
570    }
571
572    #[test]
573    fn spectral_layout_raw_is_some_for_triangle() {
574        let adj = vec![
575            vec![(1, 1.0), (2, 1.0)],
576            vec![(0, 1.0), (2, 1.0)],
577            vec![(0, 1.0), (1, 1.0)],
578        ];
579        let pos = spectral_embedding_layout(&adj).expect("spectral layout");
580        assert_eq!(pos.len(), 3);
581        assert!(layout_min_pairwise(&pos) > 1e-9);
582    }
583
584    #[test]
585    fn force_directed_still_works_explicitly() {
586        let adj = vec![vec![(1, 1.0)], vec![(0, 1.0)]];
587        let c = RegisterConstraints::neutral_atom_default();
588        let r = build_register_with(&adj, c, LayoutMethod::ForceDirected).unwrap();
589        assert_eq!(r.len(), 2);
590        let dx = r[0].1[0] - r[1].1[0];
591        let dy = r[0].1[1] - r[1].1[1];
592        let d = (dx * dx + dy * dy).sqrt();
593        assert!(d >= c.min_distance_um - 1e-6);
594    }
595
596    #[test]
597    fn spectral_and_force_both_deterministic() {
598        let adj = vec![
599            vec![(1, 1.0), (3, 1.0)],
600            vec![(0, 1.0), (2, 1.0)],
601            vec![(1, 1.0), (3, 1.0)],
602            vec![(0, 1.0), (2, 1.0)],
603        ];
604        let c = RegisterConstraints::neutral_atom_default();
605        for method in [LayoutMethod::Spectral, LayoutMethod::ForceDirected] {
606            let a = build_register_with(&adj, c, method).unwrap();
607            let b = build_register_with(&adj, c, method).unwrap();
608            assert_eq!(a, b, "method {method:?} not deterministic");
609        }
610    }
611
612    #[test]
613    fn default_method_is_spectral() {
614        assert_eq!(LayoutMethod::default(), LayoutMethod::Spectral);
615    }
616}