1use nalgebra::DMatrix;
15use serde::{Deserialize, Serialize};
16
17use crate::error::{check_positive_finite, Result, SdkError};
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct VerificationEdge {
24 pub src: usize,
25 pub dst: usize,
26 pub weight: f64,
27}
28
29impl VerificationEdge {
30 pub fn new(src: usize, dst: usize, weight: f64) -> Self {
31 Self { src, dst, weight }
32 }
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
37pub struct VerificationGraph {
38 pub node_count: usize,
39 pub edges: Vec<VerificationEdge>,
40}
41
42impl VerificationGraph {
43 pub fn new(node_count: usize) -> Self {
44 Self {
45 node_count,
46 edges: Vec::new(),
47 }
48 }
49
50 pub fn with_edge(mut self, src: usize, dst: usize, weight: f64) -> Self {
51 self.edges.push(VerificationEdge::new(src, dst, weight));
52 self
53 }
54
55 pub fn add_edge(&mut self, src: usize, dst: usize, weight: f64) {
56 self.edges.push(VerificationEdge::new(src, dst, weight));
57 }
58
59 fn laplacian(&self) -> Result<DMatrix<f64>> {
62 if self.node_count == 0 {
63 return Err(SdkError::Spectral("verification graph has no nodes".into()));
64 }
65 let n = self.node_count;
66 let mut a = DMatrix::<f64>::zeros(n, n);
67 for e in &self.edges {
68 if e.src >= n || e.dst >= n {
69 return Err(SdkError::Spectral(format!(
70 "edge ({}, {}) out of range for {} nodes",
71 e.src, e.dst, n
72 )));
73 }
74 if e.src == e.dst {
75 return Err(SdkError::Spectral(format!("self-loop at node {}", e.src)));
76 }
77 check_positive_finite(e.weight, "edge weight")?;
78 let w = e.weight;
79 a[(e.src, e.src)] += w;
80 a[(e.dst, e.dst)] += w;
81 a[(e.src, e.dst)] -= w;
82 a[(e.dst, e.src)] -= w;
83 }
84 Ok(a)
85 }
86
87 fn eigenvalues_sorted(&self) -> Result<Vec<f64>> {
89 let a = self.laplacian()?;
90 let mut eigs: Vec<f64> = a.symmetric_eigenvalues().iter().copied().collect();
92 eigs.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
93 Ok(eigs)
94 }
95
96 pub fn smallest_nonzero_eigenvalue(&self, tol: f64) -> Result<Option<f64>> {
102 let eigs = self.eigenvalues_sorted()?;
103 Ok(eigs.into_iter().find(|&v| v > tol))
104 }
105
106 pub fn mu(&self, tol: f64) -> Result<Option<f64>> {
112 Ok(self
113 .smallest_nonzero_eigenvalue(tol)?
114 .map(|lambda| 2.0 * lambda))
115 }
116
117 pub fn fiedler_value(&self, tol: f64) -> Result<Option<f64>> {
120 self.smallest_nonzero_eigenvalue(tol)
121 }
122
123 pub fn edge_mu_sensitivity(
129 &self,
130 src: usize,
131 dst: usize,
132 weight: f64,
133 tol: f64,
134 ) -> Result<f64> {
135 Ok(self.edge_sensitivity(src, dst, weight, tol)?.gap_delta)
136 }
137
138 pub fn edge_sensitivity(
147 &self,
148 src: usize,
149 dst: usize,
150 weight: f64,
151 tol: f64,
152 ) -> Result<EdgeSensitivity> {
153 let gap_before = self.mu(tol)?.unwrap_or(0.0);
154 let nullity_before = self.nullity(tol)?;
155 let mut candidate = self.clone();
156 candidate.add_edge(src, dst, weight);
157 let gap_after = candidate.mu(tol)?.unwrap_or(0.0);
158 let nullity_after = candidate.nullity(tol)?;
159 Ok(EdgeSensitivity {
160 gap_delta: gap_after - gap_before,
161 nullity_before,
162 nullity_after,
163 })
164 }
165
166 pub fn nullity(&self, tol: f64) -> Result<usize> {
169 Ok(self
170 .eigenvalues_sorted()?
171 .into_iter()
172 .filter(|e| e.abs() <= tol)
173 .count())
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq)]
179pub struct EdgeSensitivity {
180 pub gap_delta: f64,
182 pub nullity_before: usize,
184 pub nullity_after: usize,
185}
186
187impl EdgeSensitivity {
188 pub fn earns_cost(&self, tol: f64) -> bool {
190 self.gap_delta > tol || self.nullity_after < self.nullity_before
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 const TOL: f64 = 1e-9;
199
200 #[test]
201 fn path_graph_has_expected_fiedler_value() {
202 let g = VerificationGraph::new(3)
204 .with_edge(0, 1, 1.0)
205 .with_edge(1, 2, 1.0);
206 let fiedler = g.fiedler_value(TOL).unwrap().unwrap();
207 assert!((fiedler - 1.0).abs() < 1e-6, "fiedler={fiedler}");
208 let mu = g.mu(TOL).unwrap().unwrap();
209 assert!((mu - 2.0).abs() < 1e-6, "mu={mu}");
210 }
211
212 #[test]
213 fn complete_triangle_connectivity() {
214 let g = VerificationGraph::new(3)
216 .with_edge(0, 1, 1.0)
217 .with_edge(1, 2, 1.0)
218 .with_edge(0, 2, 1.0);
219 let fiedler = g.fiedler_value(TOL).unwrap().unwrap();
220 assert!((fiedler - 3.0).abs() < 1e-6, "fiedler={fiedler}");
221 }
222
223 #[test]
224 fn disconnected_graph_has_no_spectral_gap() {
225 let g = VerificationGraph::new(4)
228 .with_edge(0, 1, 1.0)
229 .with_edge(2, 3, 1.0);
230 let eigs = g.eigenvalues_sorted().unwrap();
234 let zeros = eigs.iter().filter(|&&v| v.abs() <= TOL).count();
235 assert_eq!(
236 zeros, 2,
237 "disconnected graph has multiplicity-2 zero eigenvalue"
238 );
239 }
240
241 #[test]
242 fn independent_verifier_raises_mu_more_than_redundant() {
243 let g = VerificationGraph::new(3)
245 .with_edge(0, 1, 1.0)
246 .with_edge(1, 2, 1.0);
247 let independent = g.edge_mu_sensitivity(0, 2, 1.0, TOL).unwrap();
249 let redundant = g.edge_mu_sensitivity(0, 1, 1.0, TOL).unwrap();
251 assert!(independent > 0.0);
252 assert!(
253 independent >= redundant,
254 "independent={independent} redundant={redundant}"
255 );
256 }
257
258 #[test]
259 fn weighted_edges_scale_gap() {
260 let g1 = VerificationGraph::new(2).with_edge(0, 1, 1.0);
261 let g2 = VerificationGraph::new(2).with_edge(0, 1, 2.0);
262 let m1 = g1.mu(TOL).unwrap().unwrap();
263 let m2 = g2.mu(TOL).unwrap().unwrap();
264 assert!((m2 - 2.0 * m1).abs() < 1e-6);
265 }
266
267 #[test]
268 fn rejects_self_loop_and_out_of_range() {
269 assert!(VerificationGraph::new(2)
270 .with_edge(0, 0, 1.0)
271 .mu(TOL)
272 .is_err());
273 assert!(VerificationGraph::new(2)
274 .with_edge(0, 5, 1.0)
275 .mu(TOL)
276 .is_err());
277 }
278
279 #[test]
280 fn a_connecting_verifier_shrinks_the_nullspace_even_without_moving_mu() {
281 let g = VerificationGraph::new(4)
284 .with_edge(0, 1, 1.0)
285 .with_edge(2, 3, 1.0);
286 assert_eq!(g.nullity(TOL).unwrap(), 2);
287 let sensitivity = g.edge_sensitivity(1, 2, 1.0, TOL).unwrap();
288 assert_eq!(sensitivity.nullity_before, 2);
289 assert_eq!(sensitivity.nullity_after, 1);
290 assert!(sensitivity.earns_cost(TOL));
291 }
292
293 #[test]
294 fn a_redundant_verifier_changes_neither_lever() {
295 let g = VerificationGraph::new(2).with_edge(0, 1, 1.0);
298 let sensitivity = g.edge_sensitivity(0, 1, 1e-12, TOL).unwrap();
299 assert_eq!(sensitivity.nullity_before, sensitivity.nullity_after);
300 assert!(!sensitivity.earns_cost(1e-6));
301 }
302}