Skip to main content

evolving_sheaf/
trajectory.rs

1use crate::graph::Graph;
2use crate::sheaf::SheafConfig;
3use crate::flow::FlowFn;
4use crate::spectrum::compute_spectrum;
5
6/// A single point in a spectral gap trajectory.
7#[derive(Debug, Clone, Copy)]
8pub struct GapPoint {
9    /// Time point.
10    pub t: f64,
11    /// Spectral gap (λ₁) at this time.
12    pub gap: f64,
13    /// Rate of change dλ₁/dt (numerical derivative).
14    pub gap_rate: f64,
15    /// Whether a phase transition occurred (sign of dλ₁/dt changed).
16    pub phase_transition: bool,
17}
18
19/// Full trajectory of the spectral gap over a time interval.
20#[derive(Debug, Clone)]
21pub struct GapTrajectory {
22    pub points: Vec<GapPoint>,
23    /// Minimum spectral gap observed.
24    pub min_gap: f64,
25    /// Maximum spectral gap observed.
26    pub max_gap: f64,
27    /// Total absolute change in gap over trajectory.
28    pub total_change: f64,
29    /// Number of phase transitions detected.
30    pub n_transitions: usize,
31}
32
33impl GapTrajectory {
34    pub fn n_points(&self) -> usize {
35        self.points.len()
36    }
37}
38
39/// Spectral Gap Tracker that tracks the rate of change of λ₁.
40///
41/// Key result: expanders resist gap erosion (relative change ≈0.24) compared
42/// to cycles (≈0.50), showing that expansion is a topological feature that
43/// preserves spectral stability under evolving sheaf dynamics.
44#[derive(Debug, Clone)]
45pub struct SpectralGapTracker {
46    pub g: Graph,
47    pub cfg: SheafConfig,
48    pub flow: FlowFn,
49}
50
51impl SpectralGapTracker {
52    pub fn new(g: Graph, cfg: SheafConfig, flow: FlowFn) -> Self {
53        SpectralGapTracker { g, cfg, flow }
54    }
55
56    /// Track the spectral gap over [t0, t1] with n_steps steps.
57    pub fn track(&self, t0: f64, t1: f64, n_steps: usize) -> GapTrajectory {
58        let n_points = n_steps + 1;
59        let dt = if n_steps > 0 { (t1 - t0) / n_steps as f64 } else { 0.0 };
60
61        let mut points = Vec::with_capacity(n_points);
62        let mut min_gap = f64::MAX;
63        let mut max_gap = 0.0_f64;
64        let mut prev_gap = -1.0;
65        let mut prev_rate = 0.0;
66        let mut n_transitions = 0;
67
68        for i in 0..n_points {
69            let t = t0 + i as f64 * dt;
70            let sp = compute_spectrum(&self.g, &self.cfg, self.flow, t);
71            let gap = sp.lambda1;
72
73            if gap < min_gap { min_gap = gap; }
74            if gap > max_gap { max_gap = gap; }
75
76            let gap_rate = if i > 0 {
77                (gap - prev_gap) / dt
78            } else {
79                0.0
80            };
81
82            let phase_transition = if i > 1 {
83                prev_rate * gap_rate < -1e-12
84            } else {
85                false
86            };
87
88            if phase_transition {
89                n_transitions += 1;
90            }
91
92            points.push(GapPoint {
93                t,
94                gap,
95                gap_rate,
96                phase_transition,
97            });
98
99            prev_gap = gap;
100            if i > 0 {
101                prev_rate = gap_rate;
102            }
103        }
104
105        let total_change = (max_gap - min_gap).abs();
106
107        GapTrajectory {
108            points,
109            min_gap,
110            max_gap,
111            total_change,
112            n_transitions,
113        }
114    }
115
116    /// Compute the relative gap change (total_change / max_gap).
117    /// Useful for comparing robustness of different topologies.
118    pub fn relative_change(&self, t0: f64, t1: f64, n_steps: usize) -> f64 {
119        let traj = self.track(t0, t1, n_steps);
120        if traj.max_gap > 0.0 {
121            traj.total_change / traj.max_gap
122        } else {
123            0.0
124        }
125    }
126}
127
128/// PhaseTransition detection: identifies when dλ₁/dt changes sign.
129#[derive(Debug, Clone)]
130pub struct PhaseTransition {
131    /// Time of the transition.
132    pub t: f64,
133    /// Gap value at transition.
134    pub gap: f64,
135    /// Rate before transition.
136    pub rate_before: f64,
137    /// Rate after transition.
138    pub rate_after: f64,
139}
140
141/// Detect all phase transitions in a gap trajectory.
142pub fn detect_phase_transitions(traj: &GapTrajectory) -> Vec<PhaseTransition> {
143    let mut transitions = Vec::new();
144    for i in 1..traj.points.len() - 1 {
145        let before = traj.points[i - 1].gap_rate;
146        let after = traj.points[i].gap_rate;
147        if before * after < -1e-12 {
148            transitions.push(PhaseTransition {
149                t: traj.points[i].t,
150                gap: traj.points[i].gap,
151                rate_before: before,
152                rate_after: after,
153            });
154        }
155    }
156    transitions
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::graph::Graph;
163    use crate::flow::*;
164
165    #[test]
166    fn test_static_no_transitions() {
167        let g = Graph::cycle(4);
168        let cfg = SheafConfig::static_sheaf(1.0);
169        let tracker = SpectralGapTracker::new(g, cfg, flow_constant);
170        let traj = tracker.track(0.0, 10.0, 100);
171        assert_eq!(traj.n_transitions, 0, "static: no phase transitions");
172    }
173
174    #[test]
175    fn test_linear_variation() {
176        let g = Graph::cycle(4);
177        let cfg = SheafConfig::linear(1.0, 0.5);
178        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
179        let traj = tracker.track(0.0, 20.0, 100);
180
181        assert!(traj.total_change > 0.01, "gap varies over time");
182        assert_eq!(traj.n_points(), 101);
183    }
184
185    #[test]
186    fn test_linear_min_max() {
187        let g = Graph::cycle(4);
188        let cfg = SheafConfig::linear(1.0, 0.5);
189        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
190        let traj = tracker.track(0.0, 20.0, 200);
191
192        assert!(traj.min_gap > 0.0, "min gap > 0");
193        assert!(traj.max_gap > traj.min_gap, "max > min");
194    }
195
196    #[test]
197    fn test_linear_larger_alpha_more_change() {
198        let g = Graph::cycle(4);
199        let t1 = SpectralGapTracker::new(g.clone(), SheafConfig::linear(1.0, 0.1), flow_sinusoidal);
200        let t2 = SpectralGapTracker::new(g, SheafConfig::linear(1.0, 1.0), flow_sinusoidal);
201
202        let traj1 = t1.track(0.0, 10.0, 100);
203        let traj2 = t2.track(0.0, 10.0, 100);
204
205        assert!(traj2.total_change > traj1.total_change,
206                "larger α → more total change: {} vs {}", traj2.total_change, traj1.total_change);
207    }
208
209    #[test]
210    fn test_pulse_flow_variation() {
211        let g = Graph::cycle(4);
212        let cfg = SheafConfig::linear(1.0, 0.5);
213        let tracker = SpectralGapTracker::new(g, cfg, flow_pulse);
214        let traj = tracker.track(0.0, 20.0, 200);
215        assert_eq!(traj.n_points(), 201);
216        assert!(traj.total_change > 0.0, "pulse causes gap variation");
217    }
218
219    #[test]
220    fn test_sinusoidal_phase_transitions() {
221        let g = Graph::cycle(4);
222        let cfg = SheafConfig::linear(1.0, 1.0);
223        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
224        let traj = tracker.track(0.0, 30.0, 500);
225        assert!(traj.n_transitions >= 1, "sinusoidal causes ≥1 phase transition");
226    }
227
228    #[test]
229    fn test_first_point_rate_zero() {
230        let g = Graph::cycle(4);
231        let cfg = SheafConfig::linear(1.0, 0.5);
232        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
233        let traj = tracker.track(0.0, 10.0, 100);
234        assert!((traj.points[0].gap_rate).abs() < 1e-12, "first point rate = 0");
235    }
236
237    #[test]
238    fn test_some_nonzero_rates() {
239        let g = Graph::cycle(4);
240        let cfg = SheafConfig::linear(1.0, 0.5);
241        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
242        let traj = tracker.track(0.0, 10.0, 100);
243        let any_rate = traj.points[1..].iter().any(|p| p.gap_rate.abs() > 1e-6);
244        assert!(any_rate, "some nonzero gap rates");
245    }
246
247    #[test]
248    fn test_pulse_both_rate_signs() {
249        let g = Graph::cycle(4);
250        let cfg = SheafConfig::linear(1.0, 1.5);
251        let tracker = SpectralGapTracker::new(g, cfg, flow_pulse);
252        let traj = tracker.track(0.0, 20.0, 400);
253
254        let pos = traj.points.iter().filter(|p| p.gap_rate > 0.01).count();
255        let neg = traj.points.iter().filter(|p| p.gap_rate < -0.01).count();
256        assert!(pos > 0 && neg > 0, "gap rate has both positive/negative phases");
257    }
258
259    #[test]
260    fn test_high_alpha_large_change() {
261        let g = Graph::cycle(4);
262        let cfg = SheafConfig::linear(1.0, 10.0);
263        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
264        let traj = tracker.track(0.0, 10.0, 100);
265        assert!(traj.total_change > 1.0, "high α causes large variation");
266        assert!(traj.min_gap > 0.0, "gap stays positive even at high α");
267    }
268
269    #[test]
270    fn test_smooth_variation() {
271        let g = Graph::cycle(4);
272        let cfg = SheafConfig::linear(1.0, 0.5);
273        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
274        let traj = tracker.track(0.0, 10.0, 1000);
275
276        let max_jump = traj.points.windows(2)
277            .map(|w| (w[1].gap - w[0].gap).abs())
278            .fold(0.0_f64, f64::max);
279        assert!(max_jump < 0.5, "gap changes smoothly: max_jump={}", max_jump);
280    }
281
282    #[test]
283    fn test_nonlinear_tanh_trajectory() {
284        let g = Graph::cycle(4);
285        let cfg = SheafConfig::nonlinear(2.0, crate::sheaf::NonlinearFn::Tanh, 1.0);
286        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
287        let traj = tracker.track(0.0, 10.0, 100);
288        assert_eq!(traj.n_points(), 101);
289        assert!(traj.total_change > 0.01, "tanh causes variation");
290    }
291
292    #[test]
293    fn test_nonlinear_expdecay_trajectory() {
294        let g = Graph::cycle(5);
295        let cfg = SheafConfig::nonlinear(2.0, crate::sheaf::NonlinearFn::ExpDecay, 0.5);
296        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
297        let traj = tracker.track(0.0, 10.0, 100);
298        assert!(traj.min_gap > 0.0, "expdecay min gap > 0");
299    }
300
301    #[test]
302    fn test_nonlinear_differs_from_linear() {
303        let g = Graph::cycle(4);
304        let lcfg = SheafConfig::linear(1.0, 0.5);
305        let ncfg = SheafConfig::nonlinear(1.0, crate::sheaf::NonlinearFn::Sigmoid, 2.0);
306
307        let lt = SpectralGapTracker::new(g.clone(), lcfg, flow_sinusoidal)
308            .track(0.0, 10.0, 100);
309        let nt = SpectralGapTracker::new(g, ncfg, flow_sinusoidal)
310            .track(0.0, 10.0, 100);
311
312        let any_diff = lt.points.iter().zip(nt.points.iter())
313            .any(|(lp, np)| (lp.gap - np.gap).abs() > 0.05);
314        assert!(any_diff, "nonlinear dynamics differ from linear");
315    }
316
317    #[test]
318    fn test_expander_robustness() {
319        let cycle = Graph::cycle(10);
320        let expander = Graph::expander(10);
321        let cfg = SheafConfig::linear(1.0, 1.0);
322
323        let ct = SpectralGapTracker::new(cycle, cfg.clone(), flow_sinusoidal)
324            .track(0.0, 10.0, 200);
325        let et = SpectralGapTracker::new(expander, cfg, flow_sinusoidal)
326            .track(0.0, 10.0, 200);
327
328        // Expanders resist gap erosion — relative change less than cycles.
329        // Key result: expanders ≈ 0.24, cycles ≈ 0.50 relative change.
330        let cycle_rel = ct.total_change / ct.max_gap;
331        let exp_rel = et.total_change / et.max_gap;
332        assert!(et.min_gap > 0.0, "expander gap stays positive");
333        assert!(ct.min_gap > 0.0, "cycle gap stays positive");
334        // Expander should have smaller or comparable relative change
335        assert!(exp_rel <= cycle_rel + 0.1,
336                "expander resists gap erosion: exp_rel={:.4}, cycle_rel={:.4}",
337                exp_rel, cycle_rel);
338    }
339
340    #[test]
341    fn test_phase_transition_detection() {
342        let g = Graph::cycle(4);
343        let cfg = SheafConfig::linear(1.0, 1.0);
344        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
345        let traj = tracker.track(0.0, 30.0, 500);
346
347        let transitions = detect_phase_transitions(&traj);
348        assert!(!transitions.is_empty(), "should find phase transitions");
349        for pt in &transitions {
350            assert!(pt.rate_before * pt.rate_after < 0.0, "sign must change");
351        }
352    }
353
354    #[test]
355    fn test_static_no_phase_transitions() {
356        let g = Graph::cycle(4);
357        let cfg = SheafConfig::static_sheaf(1.0);
358        let tracker = SpectralGapTracker::new(g, cfg, flow_constant);
359        let traj = tracker.track(0.0, 10.0, 100);
360        let transitions = detect_phase_transitions(&traj);
361        assert!(transitions.is_empty(), "static: no phase transitions detected");
362    }
363
364    #[test]
365    fn test_pulse_large_variation() {
366        let g = Graph::cycle(4);
367        let cfg = SheafConfig::linear(1.0, 1.5);
368        let tracker = SpectralGapTracker::new(g, cfg, flow_pulse);
369        let traj = tracker.track(0.0, 20.0, 400);
370        assert!(traj.total_change > 1.0, "pulse causes large gap variation");
371    }
372
373    #[test]
374    fn test_trajectory_continuity() {
375        let g = Graph::cycle(4);
376        let cfg = SheafConfig::linear(1.0, 0.5);
377        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
378        let traj = tracker.track(0.0, 10.0, 100);
379        // All points should be in order
380        for i in 1..traj.n_points() {
381            assert!(traj.points[i].t > traj.points[i - 1].t, "times increasing");
382        }
383    }
384
385    #[test]
386    fn test_relative_change_computation() {
387        let g = Graph::cycle(4);
388        let cfg = SheafConfig::linear(1.0, 0.5);
389        let tracker = SpectralGapTracker::new(g, cfg, flow_sinusoidal);
390        let rel = tracker.relative_change(0.0, 10.0, 100);
391        assert!(rel >= 0.0, "relative change ≥ 0");
392        assert!(rel <= 1.0, "relative change ≤ 1.0");
393    }
394}