rustygraph 0.4.2

A high-performance library for visibility graph computation from time series data
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! Horizontal and Natural visibility algorithm implementation.
//!
//! The horizontal visibility algorithm is a simpler variant where two points
//! can "see" each other if all intermediate values are strictly lower than
//! both endpoints.
//!
//! # Algorithm
//!
//! Two points (i, yi) and (j, yj) are connected if for all k between i and j:
//!
//! ```text
//! yk < min(yi, yj)
//! ```
//!
//! The natural visibility algorithm connects two data points if they can "see"
//! each other - that is, if the line segment between them is not blocked by
//! any intermediate point.
//!
//! # Algorithm
//!
//! For each pair of points (i, yi) and (j, yj), they are connected if all
//! intermediate points (k, yk) satisfy:
//!
//! ```text
//! yk < yi + (yj - yi) * (tk - ti) / (tj - ti)
//!
//! # Examples
//!
//! ```rust
//! use rustygraph::algorithms::horizontal::compute_edges;
//!
//! let series = vec![1.0, 3.0, 2.0, 4.0, 1.0];
//! let edges = compute_edges(&series);
//!
//! println!("Horizontal visibility edges: {:?}", edges);
//! ```
//!
//! See the main `VisibilityGraph` API for usage examples.
//!
//! # References
//!
//! Luque, B., Lacasa, L., Ballesteros, F., & Luque, J. (2009).
//! "Horizontal visibility graphs: Exact results for random time series."
//! Physical Review E, 80(4), 046103.
//!
//! Lacasa, L., et al. (2008). "From time series to complex networks:
//! The visibility graph." PNAS, 105(13), 4972-4975.
//!

/// Computes visibility edges.
///
/// # Arguments
///
/// - `series`: Input time series data
///
/// # Returns
///
/// Hashmap of edges as (source, target) pairs with weights
use std::collections::HashMap;
use crate::core::TimeSeries;

/// Visibility graph algorithm type.
///
/// Determines which visibility criterion is used to connect nodes.
#[derive(Debug, Clone, Copy)]
pub enum VisibilityType {
    /// Natural visibility: nodes connected if line-of-sight is not blocked
    Natural,
    /// Horizontal visibility: nodes connected if all intermediate values are lower
    Horizontal,
}

/// Visibility edges computation with custom weight function.
///
/// This struct provides a flexible way to compute visibility graph edges
/// with custom edge weights.
///
/// # Type Parameters
///
/// - `T`: Numeric type for time series values
/// - `F`: Weight function type `Fn(usize, usize, T, T) -> f64`
pub struct VisibilityEdges<'a, T, F>
where
    T: Copy + PartialOrd + Into<f64>,
    F: Fn(usize, usize, T, T) -> f64,
{
    series: &'a TimeSeries<T>,
    rule: VisibilityType,
    weight_fn: F,
}

impl<'a, T, F> VisibilityEdges<'a, T, F>
where
    T: Copy + PartialOrd + Into<f64>,
    F: Fn(usize, usize, T, T) -> f64,
{
    /// Creates a new visibility edges computation instance.
    ///
    /// # Arguments
    ///
    /// - `series`: Time series data
    /// - `rule`: Visibility algorithm type
    /// - `weight_fn`: Function to compute edge weights `(src_idx, dst_idx, src_val, dst_val) -> weight`
    ///
    /// # Examples
    ///
    /// ```rust
    /// use rustygraph::{TimeSeries, algorithms::{VisibilityEdges, VisibilityType}};
    ///
    /// let series = TimeSeries::from_raw(vec![1.0, 3.0, 2.0, 4.0]).unwrap();
    /// let edges = VisibilityEdges::new(
    ///     &series,
    ///     VisibilityType::Natural,
    ///     |_, _, vi: f64, vj: f64| (vj - vi).abs()
    /// ).compute_edges();
    /// ```
    pub fn new(series: &'a TimeSeries<T>, rule: VisibilityType, weight_fn: F) -> Self {
        Self {
            series,
            rule,
            weight_fn,
        }
    }

    /// Computes all visibility edges in the time series.
    ///
    /// Returns a hashmap of directed edges with their computed weights.
    ///
    /// # Returns
    ///
    /// `HashMap<(usize, usize), f64>` where keys are `(source, target)` node indices
    /// and values are edge weights computed by the weight function.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use rustygraph::{TimeSeries, algorithms::{VisibilityEdges, VisibilityType}};
    ///
    /// let series = TimeSeries::from_raw(vec![1.0, 3.0, 2.0, 4.0]).unwrap();
    /// let edges = VisibilityEdges::new(
    ///     &series,
    ///     VisibilityType::Natural,
    ///     |_, _, _, _| 1.0
    /// ).compute_edges();
    ///
    /// println!("Found {} edges", edges.len());
    /// ```
    pub fn compute_edges(&self) -> HashMap<(usize, usize), f64> {

        // Sequential implementation with O(n) envelope optimization
        let mut edges = HashMap::new();
        let mut stack: Vec<usize> = Vec::new();

        // Process each point in the series
        for i in 0..self.series.len() {
            // Update the envelope stack based on visibility rule
            self.update_envelope(&mut stack, i);

            // Add visible edges from points in the stack to point i
            self.add_visible_edges(&mut edges, &stack, i);

            // Push the current point onto the stack
            stack.push(i);
        }

        // Return the computed edges
        edges
    }

    fn update_envelope(&self, stack: &mut Vec<usize>, i: usize) {
        // Only update envelope for natural visibility
        if !matches!(self.rule, VisibilityType::Natural) {
            return;
        }

        // Maintain the upper envelope stack
        // Remove points that are dominated by the convex hull
        while stack.len() >= 2 {
            let j = *stack.last().unwrap();
            let k = stack[stack.len() - 2];

            // Check if point j should be popped from the envelope
            if self.should_pop(k, j, i) {
                stack.pop();
            } else {
                break;
            }
        }
    }

    // Adds visible edges from points in the stack to point i
    fn add_visible_edges(
        &self,
        edges: &mut HashMap<(usize, usize), f64>,
        stack: &[usize],
        i: usize,
    ) {
        // Check visibility from each point in the stack to point i
        for &j in stack.iter().rev() {
            if self.is_visible(j, i) {
                // Unwrap is safe here as we only process non-None values
                let vj = self.series.values[j].unwrap();
                let vi = self.series.values[i].unwrap();
                let w = (self.weight_fn)(j, i, vj, vi);
                edges.insert((j, i), w);
            } else if matches!(self.rule, VisibilityType::Horizontal) {
                break;
            }
        }
    }

    // Determines if the point at index j is visible from point i based on the visibility rule
    fn is_visible(&self, j: usize, i: usize) -> bool {
        match self.rule {
            VisibilityType::Natural => self.is_visible_natural(j, i),
            VisibilityType::Horizontal => self.is_visible_horizontal(j, i),
        }
    }

    // Determines if the point at index j is visible from point i in natural visibility
    fn is_visible_natural(&self, j: usize, i: usize) -> bool {
        let vj: f64 = self.series.values[j].unwrap().into();
        let vi: f64 = self.series.values[i].unwrap().into();

        // Use SIMD optimization when available and beneficial (x86_64 AVX2 or ARM NEON)
        #[cfg(all(feature = "simd", any(target_arch = "x86_64", target_arch = "aarch64")))]
        {
            if i - j > 8 {
                // Collect intermediate values for SIMD processing
                let intermediate: Vec<f64> = (j + 1..i)
                    .map(|k| self.series.values[k].unwrap().into())
                    .collect();
                return crate::performance::simd::SimdOps::is_visible_natural_simd(
                    vj, vi, &intermediate, j, i
                );
            }
        }

        // Standard scalar implementation
        (j + 1..i).all(|k| {
            let vk: f64 = self.series.values[k].unwrap().into();
            let line_height = vj + (vi - vj) * ((k - j) as f64 / (i - j) as f64);
            vk < line_height
        })
    }


    // Determines if the point at index j is visible from point i in horizontal visibility
    fn is_visible_horizontal(&self, j: usize, i: usize) -> bool {
        let vj = self.series.values[j].unwrap();
        let vi = self.series.values[i].unwrap();
        let min_h = if vj < vi { vj } else { vi };

        // Use SIMD optimization when available and beneficial (x86_64 AVX2 or ARM NEON)
        #[cfg(all(feature = "simd", any(target_arch = "x86_64", target_arch = "aarch64")))]
        {
            if i - j > 8 {
                let intermediate: Vec<f64> = (j + 1..i)
                    .map(|k| self.series.values[k].unwrap().into())
                    .collect();
                return crate::performance::simd::SimdOps::is_visible_horizontal_simd(
                    vj.into(), vi.into(), &intermediate
                );
            }
        }

        // Standard scalar implementation
        (j + 1..i).all(|k| self.series.values[k].unwrap() < min_h)
    }

    // Determines if the point at index j should be popped from the envelope stack
    //
    // For natural visibility graphs, we can only safely remove point j if:
    // 1. Point j cannot see point i (blocked by the line from k to i), AND
    // 2. Point j will be blocked from seeing ALL future points beyond i
    //
    // IMPORTANT: We must NEVER remove j if it's adjacent to i (j+1 == i),
    // because adjacent points always have visibility (no intermediate points to block).
    fn should_pop(&self, k: usize, j: usize, i: usize) -> bool {
        // Safety check: NEVER remove a node that's adjacent to the current node
        if self.is_adjacent(j, i) {
            return false;
        }

        let (vk, vj, vi) = self.get_values(k, j, i);
        let (tk, tj, ti) = (k as f64, j as f64, i as f64);

        let expected_height = self.calculate_expected_height(vk, vi, tk, tj, ti);

        if vj < expected_height {
            self.is_permanently_shadowed(vk, vj, vi, tk, tj, ti)
        } else {
            false
        }
    }

    /// Check if two indices are adjacent
    fn is_adjacent(&self, j: usize, i: usize) -> bool {
        j + 1 >= i
    }

    /// Get values for three indices
    fn get_values(&self, k: usize, j: usize, i: usize) -> (f64, f64, f64) {
        (
            self.series.values[k].unwrap().into(),
            self.series.values[j].unwrap().into(),
            self.series.values[i].unwrap().into(),
        )
    }

    /// Calculate expected height of line from k to i at position j
    fn calculate_expected_height(&self, vk: f64, vi: f64, tk: f64, tj: f64, ti: f64) -> f64 {
        vk + (vi - vk) * ((tj - tk) / (ti - tk))
    }

    /// Check if point j is in a permanently shadowed position
    fn is_permanently_shadowed(&self, vk: f64, vj: f64, vi: f64, tk: f64, tj: f64, ti: f64) -> bool {
        let slope_kj = (vj - vk) / (tj - tk);
        let slope_ki = (vi - vk) / (ti - tk);
        slope_kj < slope_ki
    }
}
/// Parallel edge computation (when parallel feature is enabled).
///
/// This implementation splits the work of computing edges across multiple threads,
/// providing significant speedup for large graphs.
#[cfg(feature = "parallel")]
impl<'a, T, F> VisibilityEdges<'a, T, F>
where
    T: Copy + PartialOrd + Into<f64> + Send + Sync,
    F: Fn(usize, usize, T, T) -> f64 + Send + Sync,
{
    /// Computes edges in parallel using Rayon with O(n) envelope optimization per chunk.
    ///
    /// This method processes chunks of the time series in parallel, using the
    /// O(n) envelope optimization within each chunk for efficiency.
    ///
    /// # Strategy
    ///
    /// - Splits the series into chunks (one per thread)
    /// - Each chunk uses the sequential O(n) envelope algorithm
    /// - Results are merged at the end
    ///
    /// # Performance
    ///
    /// Expected speedup: 2-4x on multi-core systems (4-8 cores)
    /// Complexity: O(n²/p) where p is the number of threads
    ///
    /// # Returns
    ///
    /// HashMap of edges with weights, same as sequential version
    pub fn compute_edges_parallel(&self) -> HashMap<(usize, usize), f64> {
        let n = self.series.len();
        if self.should_use_sequential(n) {
            return self.compute_edges();
        }

        let chunk_results = self.process_chunks_in_parallel(n);
        self.merge_chunk_results(chunk_results)
    }

    /// Check if sequential processing is better for small graphs
    fn should_use_sequential(&self, n: usize) -> bool {
        n <= 100
    }

    /// Process chunks in parallel
    fn process_chunks_in_parallel(&self, n: usize) -> Vec<HashMap<(usize, usize), f64>> {
        use rayon::prelude::*;

        (0..n)
            .collect::<Vec<_>>()
            .par_chunks(64)
            .map(|target_chunk| self.process_target_chunk(target_chunk))
            .collect()
    }

    /// Process a single chunk of target nodes
    fn process_target_chunk(&self, target_chunk: &[usize]) -> HashMap<(usize, usize), f64> {
        let mut local_edges = HashMap::new();

        for &i in target_chunk {
            let stack = self.build_envelope_stack_for_target(i);
            self.add_visible_edges_from_stack(&mut local_edges, &stack, i);
        }

        local_edges
    }

    /// Build envelope stack for a target node
    fn build_envelope_stack_for_target(&self, target: usize) -> Vec<usize> {
        let mut stack = Vec::new();

        for j in 0..target {
            self.update_envelope_for_parallel(&mut stack, j);
            stack.push(j);
        }

        stack
    }

    /// Update envelope during parallel processing
    fn update_envelope_for_parallel(&self, stack: &mut Vec<usize>, j: usize) {
        if matches!(self.rule, VisibilityType::Natural) {
            while stack.len() >= 2 {
                let prev_j = *stack.last().unwrap();
                let prev_k = stack[stack.len() - 2];
                if self.should_pop(prev_k, prev_j, j) {
                    stack.pop();
                } else {
                    break;
                }
            }
        }
    }

    /// Add visible edges from stack to target node
    fn add_visible_edges_from_stack(
        &self,
        edges: &mut HashMap<(usize, usize), f64>,
        stack: &[usize],
        target: usize,
    ) {
        for &j in stack.iter().rev() {
            if self.is_visible(j, target) {
                let vj = self.series.values[j].unwrap();
                let vi = self.series.values[target].unwrap();
                let w = (self.weight_fn)(j, target, vj, vi);
                edges.insert((j, target), w);
            } else if matches!(self.rule, VisibilityType::Horizontal) {
                break;
            }
        }
    }

    /// Merge results from all chunks
    fn merge_chunk_results(&self, chunk_results: Vec<HashMap<(usize, usize), f64>>) -> HashMap<(usize, usize), f64> {
        let mut edges = HashMap::new();
        for chunk_edges in chunk_results {
            edges.extend(chunk_edges);
        }
        edges
    }
}