Skip to main content

fdars_core/frechet/spaces/
network.rs

1//! Network (graph-Laplacian) `MetricSpace` backend (FRE-02-04).
2//!
3//! Objects are graph Laplacians of `d`-node graphs, stored as flat column-major
4//! `Vec<f64>` of length `d*d`. The metric is the element-wise Frobenius distance;
5//! the weighted Fréchet mean is the weighted average, which stays a valid
6//! Laplacian for non-negative weights (Laplacians form a convex cone). Laplacian
7//! structure is not re-validated per call — supplying valid Laplacians is the
8//! caller's responsibility.
9//!
10//! # Divergence from R `frechet` 0.3.0
11//!
12//! R's network-response geometry may use a different graph metric; this backend
13//! uses Frobenius distance on the Laplacian representation. The capability
14//! (distance + weighted Fréchet mean over network responses) matches.
15
16use crate::error::FdarError;
17use crate::frechet::MetricSpace;
18use crate::helpers::NUMERICAL_EPS;
19
20/// Network response space over `d`-node graph Laplacians (FRE-02-04).
21#[derive(Debug, Clone, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct NetworkSpace {
24    /// Number of graph nodes `d` (objects are `d*d` flat Laplacians).
25    pub d: usize,
26}
27
28impl NetworkSpace {
29    /// Construct a network space over `d`-node graphs.
30    ///
31    /// # Errors
32    /// [`FdarError::InvalidParameter`] if `d < 1`.
33    pub fn new(d: usize) -> Result<Self, FdarError> {
34        if d < 1 {
35            return Err(FdarError::InvalidParameter {
36                parameter: "d",
37                message: "number of nodes must be >= 1".to_string(),
38            });
39        }
40        Ok(Self { d })
41    }
42
43    fn check_len(&self, obj: &[f64], name: &'static str) -> Result<(), FdarError> {
44        let dd = self.d * self.d;
45        if obj.len() != dd {
46            return Err(FdarError::InvalidDimension {
47                parameter: name,
48                expected: format!("{dd} elements (d*d)"),
49                actual: format!("{} elements", obj.len()),
50            });
51        }
52        Ok(())
53    }
54}
55
56impl MetricSpace for NetworkSpace {
57    type Object = Vec<f64>;
58
59    fn distance(&self, a: &Self::Object, b: &Self::Object) -> Result<f64, FdarError> {
60        self.check_len(a, "a")?;
61        self.check_len(b, "b")?;
62        Ok(a.iter()
63            .zip(b.iter())
64            .map(|(x, y)| (x - y) * (x - y))
65            .sum::<f64>()
66            .sqrt())
67    }
68
69    fn weighted_frechet_mean(
70        &self,
71        objects: &[Self::Object],
72        weights: &[f64],
73    ) -> Result<Self::Object, FdarError> {
74        if objects.is_empty() {
75            return Err(FdarError::InvalidDimension {
76                parameter: "objects",
77                expected: "at least 1 object".to_string(),
78                actual: "0 objects".to_string(),
79            });
80        }
81        if weights.len() != objects.len() {
82            return Err(FdarError::InvalidDimension {
83                parameter: "weights",
84                expected: format!("{} weights (matching objects)", objects.len()),
85                actual: format!("{} weights", weights.len()),
86            });
87        }
88        let dd = self.d * self.d;
89        for (i, o) in objects.iter().enumerate() {
90            if o.len() != dd {
91                return Err(FdarError::InvalidDimension {
92                    parameter: "objects",
93                    expected: format!("each object has {dd} elements"),
94                    actual: format!("object {i} has {} elements", o.len()),
95                });
96            }
97        }
98        let sw: f64 = weights.iter().sum();
99        if sw.abs() < NUMERICAL_EPS {
100            return Err(FdarError::ComputationFailed {
101                operation: "NetworkSpace::weighted_frechet_mean",
102                detail: "sum of weights is ~0; cannot normalize the barycenter".to_string(),
103            });
104        }
105        let mut m = vec![0.0f64; dd];
106        for (o, &w) in objects.iter().zip(weights.iter()) {
107            for (k, mk) in m.iter_mut().enumerate() {
108                *mk += w * o[k];
109            }
110        }
111        for x in &mut m {
112            *x /= sw;
113        }
114        Ok(m)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    // 3-node path-graph Laplacian (flat column-major, symmetric so layout-agnostic).
123    fn laplacian_path3() -> Vec<f64> {
124        // rows: [1,-1,0; -1,2,-1; 0,-1,1]
125        vec![1.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 1.0]
126    }
127
128    fn laplacian_triangle3() -> Vec<f64> {
129        // complete graph on 3 nodes: [2,-1,-1; -1,2,-1; -1,-1,2]
130        vec![2.0, -1.0, -1.0, -1.0, 2.0, -1.0, -1.0, -1.0, 2.0]
131    }
132
133    fn row_sums(m: &[f64], d: usize) -> Vec<f64> {
134        (0..d)
135            .map(|i| (0..d).map(|j| m[i + j * d]).sum::<f64>())
136            .collect()
137    }
138
139    #[test]
140    fn network_distance_of_identical_is_zero() {
141        let s = NetworkSpace::new(3).unwrap();
142        let a = laplacian_path3();
143        assert!(s.distance(&a, &a).unwrap() < 1e-12);
144    }
145
146    #[test]
147    fn network_mean_of_identical_recovers() {
148        let s = NetworkSpace::new(3).unwrap();
149        let a = laplacian_path3();
150        let m = s
151            .weighted_frechet_mean(&[a.clone(), a.clone()], &[0.5, 0.5])
152            .unwrap();
153        for (x, y) in m.iter().zip(a.iter()) {
154            assert!((x - y).abs() < 1e-10);
155        }
156    }
157
158    #[test]
159    fn network_mean_preserves_row_sums() {
160        let s = NetworkSpace::new(3).unwrap();
161        let m = s
162            .weighted_frechet_mean(&[laplacian_path3(), laplacian_triangle3()], &[0.4, 0.6])
163            .unwrap();
164        for rs in row_sums(&m, 3) {
165            assert!(rs.abs() < 1e-10, "row sum {rs} != 0");
166        }
167    }
168
169    #[test]
170    fn network_rejects_dimension_mismatch() {
171        let s = NetworkSpace::new(3).unwrap();
172        let a = laplacian_path3();
173        let bad = vec![0.0; 4];
174        assert!(matches!(
175            s.distance(&a, &bad),
176            Err(FdarError::InvalidDimension { .. })
177        ));
178    }
179}