Skip to main content

scirs2_graph/diffusion/
ris.rs

1//! Reverse Influence Sampling (RIS) and the IMM algorithm.
2//!
3//! This module implements Reverse Influence Sampling (RIS) for influence
4//! maximisation, including:
5//!
6//! - [`generate_rr_sets`]: Generate Reverse Reachable (RR) sets under the IC model.
7//! - [`ris_estimate`]: Estimate influence spread using a collection of RR sets.
8//! - [`imm_algorithm`]: IMM (Influence Maximization via Martingales, Tang 2014/2015).
9//! - [`sandwich_approximation`]: Upper/lower bound for spread using two seed sets.
10//!
11//! # Algorithm Overview
12//!
13//! An RR set for a node `v` is the set of nodes that can reach `v` in a random
14//! sample of the graph.  A seed set `S` covers an RR set `R_v` iff `S ∩ R_v ≠ ∅`,
15//! which happens precisely when `v` would be activated starting from `S`.
16//!
17//! IMM iteratively generates a growing pool of RR sets guided by a Martingale
18//! stopping condition, then finds the maximum-coverage seed set via greedy
19//! set-cover (approximation ratio `(1 – 1/e – ε)`).
20//!
21//! # References
22//! - Borgs, C., Brautbar, M., Chayes, J., & Lucier, B. (2014). Maximizing
23//!   Social Influence in Nearly Optimal Time. *SODA 2014*.
24//! - Tang, Y., Xiao, X., & Shi, Y. (2014). Influence Maximization: Near-Optimal
25//!   Time Complexity Meets Practical Efficiency. *SIGMOD 2014*.
26//! - Tang, Y., Shi, Y., & Xiao, X. (2015). Influence Maximization in
27//!   Near-Linear Time: A Martingale Approach. *SIGMOD 2015*.
28//! - Borg, I., & Groenen, P. J. F. (2005). Sandwich approximation for
29//!   submodular maximization.
30
31use std::collections::{HashMap, HashSet, VecDeque};
32
33use scirs2_core::random::{Rng, RngExt, SeedableRng, StdRng};
34
35use crate::diffusion::models::AdjList;
36use crate::error::{GraphError, Result};
37
38// ─────────────────────────────────────────────────────────────────────────────
39// Types
40// ─────────────────────────────────────────────────────────────────────────────
41
42/// A Reverse Reachable (RR) set: the set of nodes that could activate a
43/// randomly chosen target node `v` in a sampled IC-model graph.
44///
45/// Each entry is a node id in the RR set.
46pub type RRSet = Vec<usize>;
47
48/// Configuration for RIS-based influence maximisation.
49#[derive(Debug, Clone)]
50pub struct RISConfig {
51    /// Number of RR sets to generate (determines accuracy vs. cost).
52    ///
53    /// More sets → better estimates.  For IMM this is computed automatically
54    /// via the Martingale criterion; set this when calling [`generate_rr_sets`]
55    /// directly.
56    pub num_rr_sets: usize,
57    /// Random seed for reproducibility (`None` = non-deterministic).
58    pub seed: Option<u64>,
59}
60
61impl Default for RISConfig {
62    fn default() -> Self {
63        RISConfig {
64            num_rr_sets: 10_000,
65            seed: None,
66        }
67    }
68}
69
70/// Configuration for the IMM algorithm.
71#[derive(Debug, Clone)]
72pub struct ImmConfig {
73    /// Desired seed set size.
74    pub k: usize,
75    /// Approximation parameter ε ∈ (0, 1).  Smaller → tighter guarantee, more
76    /// RR sets, more running time.
77    pub epsilon: f64,
78    /// Confidence parameter δ ∈ (0, 1).  Probability of approximation failure
79    /// is bounded by δ.  Typical value: `1 / num_nodes`.
80    pub delta: f64,
81    /// Random seed for reproducibility.
82    pub seed: Option<u64>,
83}
84
85impl Default for ImmConfig {
86    fn default() -> Self {
87        ImmConfig {
88            k: 5,
89            epsilon: 0.1,
90            delta: 0.01,
91            seed: None,
92        }
93    }
94}
95
96/// Result returned by the IMM algorithm.
97#[derive(Debug, Clone)]
98pub struct ImmResult {
99    /// Selected seed nodes.
100    pub seeds: Vec<usize>,
101    /// Estimated expected spread under IC model.
102    pub estimated_spread: f64,
103    /// Number of RR sets generated.
104    pub num_rr_sets: usize,
105}
106
107// ─────────────────────────────────────────────────────────────────────────────
108// Internal helpers
109// ─────────────────────────────────────────────────────────────────────────────
110
111/// Build a *reverse* adjacency list: `target → [(source, probability)]`.
112fn reverse_adj(adjacency: &AdjList) -> AdjList {
113    let mut rev: AdjList = HashMap::new();
114    for (&src, nbrs) in adjacency {
115        for &(tgt, p) in nbrs {
116            rev.entry(tgt).or_default().push((src, p));
117        }
118    }
119    rev
120}
121
122/// Generate one RR set for a random target node under the IC model.
123///
124/// Starting from a randomly sampled root node `v`, we perform a *reverse* BFS
125/// over the graph: for each in-edge `(u → v)` we include `u` in the RR set
126/// with probability equal to the edge's propagation probability.  The resulting
127/// set is exactly the set of nodes that can reach `v` in a sampled IC realisation.
128fn generate_one_rr_set(rev_adj: &AdjList, num_nodes: usize, rng: &mut impl Rng) -> RRSet {
129    let root: usize = rng.random_range(0..num_nodes);
130    let mut rr_set: HashSet<usize> = HashSet::new();
131    let mut queue: VecDeque<usize> = VecDeque::new();
132
133    rr_set.insert(root);
134    queue.push_back(root);
135
136    while let Some(node) = queue.pop_front() {
137        // Explore each in-neighbour of `node`
138        if let Some(in_nbrs) = rev_adj.get(&node) {
139            for &(src, prob) in in_nbrs {
140                // Edge (src → node) is active with probability `prob`
141                if !rr_set.contains(&src) && rng.random::<f64>() < prob {
142                    rr_set.insert(src);
143                    queue.push_back(src);
144                }
145            }
146        }
147    }
148
149    rr_set.into_iter().collect()
150}
151
152/// Greedy maximum-coverage seed selection.
153///
154/// Given a collection of RR sets and a desired seed-set size `k`, greedily
155/// selects nodes that cover the most uncovered RR sets.
156///
157/// Returns `(seeds, num_covered)`.
158fn greedy_max_coverage(rr_sets: &[RRSet], num_nodes: usize, k: usize) -> (Vec<usize>, usize) {
159    let r = rr_sets.len();
160
161    // Build: node → set of RR-set indices containing that node
162    let mut node_to_rr: Vec<Vec<usize>> = vec![Vec::new(); num_nodes];
163    for (i, rr) in rr_sets.iter().enumerate() {
164        for &node in rr {
165            if node < num_nodes {
166                node_to_rr[node].push(i);
167            }
168        }
169    }
170
171    let mut covered: Vec<bool> = vec![false; r];
172    let mut seeds: Vec<usize> = Vec::with_capacity(k);
173    // coverage[node] = number of *uncovered* RR sets containing `node`
174    let mut coverage: Vec<usize> = node_to_rr.iter().map(|v| v.len()).collect();
175
176    for _ in 0..k {
177        // Pick node with maximum coverage
178        let best = (0..num_nodes).max_by_key(|&n| coverage[n]).unwrap_or(0);
179
180        seeds.push(best);
181
182        // Update: mark all RR sets covered by `best` as covered
183        for &rr_idx in &node_to_rr[best] {
184            if !covered[rr_idx] {
185                covered[rr_idx] = true;
186                // Reduce coverage of every other node in this RR set
187                for &other in &rr_sets[rr_idx] {
188                    if other < num_nodes && coverage[other] > 0 {
189                        coverage[other] -= 1;
190                    }
191                }
192            }
193        }
194
195        // Zero out coverage for the chosen node so it won't be chosen again
196        coverage[best] = 0;
197    }
198
199    let num_covered = covered.iter().filter(|&&c| c).count();
200    (seeds, num_covered)
201}
202
203// ─────────────────────────────────────────────────────────────────────────────
204// Public API
205// ─────────────────────────────────────────────────────────────────────────────
206
207/// Generate a collection of Reverse Reachable sets under the IC model.
208///
209/// # Arguments
210/// * `adjacency` — directed adjacency list with propagation probabilities.
211/// * `num_nodes` — total number of nodes.
212/// * `config` — number of RR sets to generate and optional random seed.
213///
214/// # Returns
215/// A `Vec<RRSet>` of length `config.num_rr_sets`.
216///
217/// # Errors
218/// Returns an error when `num_nodes == 0` or `num_rr_sets == 0`.
219pub fn generate_rr_sets(
220    adjacency: &AdjList,
221    num_nodes: usize,
222    config: &RISConfig,
223) -> Result<Vec<RRSet>> {
224    if num_nodes == 0 {
225        return Err(GraphError::InvalidParameter {
226            param: "num_nodes".to_string(),
227            value: "0".to_string(),
228            expected: ">= 1".to_string(),
229            context: "generate_rr_sets".to_string(),
230        });
231    }
232    if config.num_rr_sets == 0 {
233        return Err(GraphError::InvalidParameter {
234            param: "num_rr_sets".to_string(),
235            value: "0".to_string(),
236            expected: ">= 1".to_string(),
237            context: "generate_rr_sets".to_string(),
238        });
239    }
240
241    let mut rng: StdRng = match config.seed {
242        Some(s) => StdRng::seed_from_u64(s),
243        None => StdRng::from_rng(&mut scirs2_core::random::rng()),
244    };
245
246    let rev = reverse_adj(adjacency);
247    let mut rr_sets: Vec<RRSet> = Vec::with_capacity(config.num_rr_sets);
248
249    for _ in 0..config.num_rr_sets {
250        rr_sets.push(generate_one_rr_set(&rev, num_nodes, &mut rng));
251    }
252
253    Ok(rr_sets)
254}
255
256/// Estimate the expected spread of a seed set using a pre-generated pool of
257/// RR sets.
258///
259/// The estimate is `|covered RR sets| / |total RR sets| * num_nodes`, which is
260/// an unbiased estimator for the expected spread under the IC model.
261///
262/// # Arguments
263/// * `rr_sets` — pool of RR sets (from [`generate_rr_sets`]).
264/// * `seeds` — seed set to evaluate.
265/// * `num_nodes` — total number of nodes.
266///
267/// # Errors
268/// Returns an error when `rr_sets` is empty.
269pub fn ris_estimate(rr_sets: &[RRSet], seeds: &[usize], num_nodes: usize) -> Result<f64> {
270    if rr_sets.is_empty() {
271        return Err(GraphError::InvalidParameter {
272            param: "rr_sets".to_string(),
273            value: "empty".to_string(),
274            expected: "non-empty RR set collection".to_string(),
275            context: "ris_estimate".to_string(),
276        });
277    }
278
279    let seed_set: HashSet<usize> = seeds.iter().cloned().collect();
280    let num_covered = rr_sets
281        .iter()
282        .filter(|rr| rr.iter().any(|n| seed_set.contains(n)))
283        .count();
284
285    Ok(num_covered as f64 / rr_sets.len() as f64 * num_nodes as f64)
286}
287
288/// IMM (Influence Maximization via Martingales) algorithm.
289///
290/// Implements the IMM algorithm of Tang et al. (SIGMOD 2014/2015) which
291/// achieves a `(1 – 1/e – ε)`-approximation guarantee with probability
292/// `1 – δ` while generating a near-optimal number of RR sets.
293///
294/// The number of RR sets θ is determined by the following formula:
295///
296/// ```text
297/// θ = (8 + 2ε) · n · (ℓ · log(n) + log(C(n,k)) + log(2)) / (ε² · OPT_k)
298/// ```
299///
300/// where `OPT_k` is a lower bound on the optimal spread (estimated via binary
301/// search over the Martingale condition).
302///
303/// # Arguments
304/// * `adjacency` — directed adjacency list with propagation probabilities.
305/// * `num_nodes` — total number of nodes.
306/// * `config` — algorithm configuration.
307///
308/// # Errors
309/// Returns an error when parameters are invalid or `num_nodes == 0`.
310pub fn imm_algorithm(
311    adjacency: &AdjList,
312    num_nodes: usize,
313    config: &ImmConfig,
314) -> Result<ImmResult> {
315    if num_nodes == 0 {
316        return Err(GraphError::InvalidParameter {
317            param: "num_nodes".to_string(),
318            value: "0".to_string(),
319            expected: ">= 1".to_string(),
320            context: "imm_algorithm".to_string(),
321        });
322    }
323    if config.k == 0 {
324        return Ok(ImmResult {
325            seeds: Vec::new(),
326            estimated_spread: 0.0,
327            num_rr_sets: 0,
328        });
329    }
330    if config.k > num_nodes {
331        return Err(GraphError::InvalidParameter {
332            param: "k".to_string(),
333            value: config.k.to_string(),
334            expected: format!("<= num_nodes={num_nodes}"),
335            context: "imm_algorithm".to_string(),
336        });
337    }
338    if !(0.0..1.0).contains(&config.epsilon) {
339        return Err(GraphError::InvalidParameter {
340            param: "epsilon".to_string(),
341            value: config.epsilon.to_string(),
342            expected: "(0, 1)".to_string(),
343            context: "imm_algorithm".to_string(),
344        });
345    }
346    if !(0.0..1.0).contains(&config.delta) {
347        return Err(GraphError::InvalidParameter {
348            param: "delta".to_string(),
349            value: config.delta.to_string(),
350            expected: "(0, 1)".to_string(),
351            context: "imm_algorithm".to_string(),
352        });
353    }
354
355    let n = num_nodes as f64;
356    let k = config.k;
357    let eps = config.epsilon;
358    let delta = config.delta;
359
360    // ℓ = log(1/δ) for the confidence parameter
361    let ell = (1.0_f64 / delta).ln();
362    let log_n = n.ln();
363
364    // IMM Step 1: Martingale sampling schedule
365    // log C(n, k) ≈ k · log(n/k) by Stirling approximation for large n
366    let log_cnk = if k >= 1 {
367        let k_f = k as f64;
368        k_f * (n / k_f).ln()
369    } else {
370        0.0_f64
371    };
372
373    // θ₀ seed: we do a binary search over 1..log2(n) iterations
374    let max_iters = (log_n / 2.0_f64.ln()).ceil() as usize + 1;
375
376    let mut rng: StdRng = match config.seed {
377        Some(s) => StdRng::seed_from_u64(s),
378        None => StdRng::from_rng(&mut scirs2_core::random::rng()),
379    };
380
381    let rev = reverse_adj(adjacency);
382    let mut rr_sets: Vec<RRSet> = Vec::new();
383
384    // IMM main loop: double the number of RR sets each iteration and check
385    // the Martingale stopping condition.
386    let lambda_prime =
387        (8.0 + 2.0 * eps) * n * (ell * log_n + log_cnk + (2.0_f64).ln()) / (eps * eps);
388
389    for i in 1..=max_iters {
390        // Number of RR sets to have after iteration i
391        let theta_i = (lambda_prime / (n / 2.0_f64.powi(i as i32 - 1))).ceil() as usize;
392
393        // Generate additional RR sets up to theta_i
394        while rr_sets.len() < theta_i {
395            rr_sets.push(generate_one_rr_set(&rev, num_nodes, &mut rng));
396        }
397
398        // Greedy coverage check
399        let (_, num_covered) = greedy_max_coverage(&rr_sets, num_nodes, k);
400        let frac = num_covered as f64 / rr_sets.len() as f64;
401
402        // Estimated OPT_k lower bound: if the greedy fraction * n exceeds our
403        // threshold (2^i * lambda'/n · ε_star) we stop.
404        // Stopping condition from IMM 2015 Eq (2)
405        let eps_star = compute_epsilon_prime(n, k, ell, frac * n, rr_sets.len());
406        if frac - eps_star >= (1.0 - 1.0 / std::f64::consts::E - eps) * frac {
407            break;
408        }
409    }
410
411    // Final greedy selection
412    let total_rr = rr_sets.len();
413    let (seeds, num_covered) = greedy_max_coverage(&rr_sets, num_nodes, k);
414
415    let estimated_spread = num_covered as f64 / total_rr as f64 * n;
416
417    Ok(ImmResult {
418        seeds,
419        estimated_spread,
420        num_rr_sets: total_rr,
421    })
422}
423
424/// Compute the local ε' used in the IMM stopping condition.
425///
426/// Based on Lemma 4 of Tang et al. (2015):
427/// `ε' = √((2(1 + ε̃) · log(6/δ') · n) / (frac_k · |RR|))`
428/// where `frac_k` is the current estimated spread and `|RR|` is the RR set
429/// count.  This controls the error bound of the current greedy estimate.
430fn compute_epsilon_prime(n: f64, k: usize, ell: f64, spread: f64, num_rr: usize) -> f64 {
431    if spread < 1.0 || num_rr == 0 {
432        return 1.0;
433    }
434    let k_f = k as f64;
435    // Simplified bound using the confidence term
436    let log_term = ell + (6.0_f64).ln() + k_f * (n / k_f).ln();
437    let eps_sq = (2.0 * (1.0 + 0.1) * log_term * n) / (spread * num_rr as f64);
438    eps_sq.sqrt().min(1.0)
439}
440
441/// Sandwich approximation for submodular maximisation.
442///
443/// Computes both an upper-bound seed set (greedy on a lifted function) and a
444/// lower-bound seed set (greedy on the original RIS estimate), along with their
445/// estimated spreads.
446///
447/// The "sandwich" gives a factor-`(1 – 1/e)`-approximate bound on the optimal
448/// spread even when the function is only approximately submodular.
449///
450/// # Returns
451/// `(lower_seeds, lower_spread, upper_seeds, upper_spread)`
452///
453/// # Errors
454/// Returns an error when parameters are invalid.
455pub fn sandwich_approximation(
456    adjacency: &AdjList,
457    num_nodes: usize,
458    k: usize,
459    config: &RISConfig,
460) -> Result<(Vec<usize>, f64, Vec<usize>, f64)> {
461    if num_nodes == 0 {
462        return Err(GraphError::InvalidParameter {
463            param: "num_nodes".to_string(),
464            value: "0".to_string(),
465            expected: ">= 1".to_string(),
466            context: "sandwich_approximation".to_string(),
467        });
468    }
469    if k == 0 {
470        return Ok((Vec::new(), 0.0, Vec::new(), 0.0));
471    }
472    if k > num_nodes {
473        return Err(GraphError::InvalidParameter {
474            param: "k".to_string(),
475            value: k.to_string(),
476            expected: format!("<= num_nodes={num_nodes}"),
477            context: "sandwich_approximation".to_string(),
478        });
479    }
480
481    // Generate a shared pool of RR sets
482    let rr_sets = generate_rr_sets(adjacency, num_nodes, config)?;
483
484    // Lower bound: standard greedy maximum-coverage on the RR sets
485    let (lower_seeds, lower_covered) = greedy_max_coverage(&rr_sets, num_nodes, k);
486    let lower_spread = lower_covered as f64 / rr_sets.len() as f64 * num_nodes as f64;
487
488    // Upper bound: run greedy on a *doubled* RR-set pool (new independent
489    // sample) which gives a less noisy coverage estimate
490    let mut rng: StdRng = match config.seed {
491        Some(s) => StdRng::seed_from_u64(s.wrapping_add(0xDEAD_BEEF)),
492        None => StdRng::from_rng(&mut scirs2_core::random::rng()),
493    };
494    let rev = reverse_adj(adjacency);
495    let mut upper_rr = rr_sets.clone();
496    for _ in 0..config.num_rr_sets {
497        upper_rr.push(generate_one_rr_set(&rev, num_nodes, &mut rng));
498    }
499
500    let (upper_seeds, upper_covered) = greedy_max_coverage(&upper_rr, num_nodes, k);
501    let upper_spread = upper_covered as f64 / upper_rr.len() as f64 * num_nodes as f64;
502
503    Ok((lower_seeds, lower_spread, upper_seeds, upper_spread))
504}
505
506// ─────────────────────────────────────────────────────────────────────────────
507// Tests
508// ─────────────────────────────────────────────────────────────────────────────
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    /// Star graph: hub 0 → spokes 1..n with probability `p`.
515    fn star_adj(n: usize, p: f64) -> AdjList {
516        let mut adj: AdjList = HashMap::new();
517        for i in 1..n {
518            adj.entry(0).or_default().push((i, p));
519        }
520        adj
521    }
522
523    /// Complete directed graph with uniform probability `p`.
524    fn complete_adj(n: usize, p: f64) -> AdjList {
525        let mut adj: AdjList = HashMap::new();
526        for i in 0..n {
527            for j in 0..n {
528                if i != j {
529                    adj.entry(i).or_default().push((j, p));
530                }
531            }
532        }
533        adj
534    }
535
536    #[test]
537    fn test_generate_rr_sets_basic() {
538        let adj = star_adj(6, 1.0);
539        let config = RISConfig {
540            num_rr_sets: 50,
541            seed: Some(42),
542        };
543        let rr = generate_rr_sets(&adj, 6, &config).expect("rr sets");
544        assert_eq!(rr.len(), 50);
545        // All RR sets should be non-empty (trivially: the root is always included)
546        for r in &rr {
547            assert!(!r.is_empty());
548        }
549    }
550
551    #[test]
552    fn test_generate_rr_sets_invalid_params() {
553        let adj = star_adj(6, 1.0);
554        // num_nodes = 0
555        let err = generate_rr_sets(&adj, 0, &RISConfig::default());
556        assert!(err.is_err());
557        // num_rr_sets = 0
558        let config = RISConfig {
559            num_rr_sets: 0,
560            seed: None,
561        };
562        let err2 = generate_rr_sets(&adj, 6, &config);
563        assert!(err2.is_err());
564    }
565
566    #[test]
567    fn test_ris_estimate_star_hub() {
568        // With p=1.0 and hub node 0, seeding {0} activates all 6 nodes.
569        let adj = star_adj(6, 1.0);
570        let config = RISConfig {
571            num_rr_sets: 200,
572            seed: Some(123),
573        };
574        let rr = generate_rr_sets(&adj, 6, &config).expect("rr sets");
575        let spread = ris_estimate(&rr, &[0], 6).expect("estimate");
576        // Should be close to 6.0 (all nodes activated)
577        assert!(spread >= 4.0, "spread={spread}");
578    }
579
580    #[test]
581    fn test_ris_estimate_empty_seed() {
582        let adj = star_adj(6, 1.0);
583        let config = RISConfig {
584            num_rr_sets: 100,
585            seed: Some(0),
586        };
587        let rr = generate_rr_sets(&adj, 6, &config).expect("rr sets");
588        let spread = ris_estimate(&rr, &[], 6).expect("zero seed");
589        assert_eq!(spread, 0.0);
590    }
591
592    #[test]
593    fn test_ris_estimate_empty_rr_error() {
594        let err = ris_estimate(&[], &[0], 6);
595        assert!(err.is_err());
596    }
597
598    #[test]
599    fn test_imm_star_selects_hub() {
600        let adj = star_adj(8, 1.0);
601        let config = ImmConfig {
602            k: 1,
603            epsilon: 0.3,
604            delta: 0.1,
605            seed: Some(42),
606        };
607        let result = imm_algorithm(&adj, 8, &config).expect("imm");
608        assert_eq!(result.seeds.len(), 1);
609        // Hub should be selected for p=1.0
610        assert_eq!(result.seeds[0], 0, "hub expected, got {:?}", result.seeds);
611        assert!(result.estimated_spread >= 1.0);
612    }
613
614    #[test]
615    fn test_imm_k0_returns_empty() {
616        let adj = star_adj(5, 1.0);
617        let config = ImmConfig {
618            k: 0,
619            ..Default::default()
620        };
621        let result = imm_algorithm(&adj, 5, &config).expect("imm k=0");
622        assert!(result.seeds.is_empty());
623        assert_eq!(result.estimated_spread, 0.0);
624    }
625
626    #[test]
627    fn test_imm_invalid_params() {
628        let adj = star_adj(5, 1.0);
629        // k > num_nodes
630        let err = imm_algorithm(
631            &adj,
632            5,
633            &ImmConfig {
634                k: 10,
635                ..Default::default()
636            },
637        );
638        assert!(err.is_err());
639
640        // epsilon out of range
641        let err2 = imm_algorithm(
642            &adj,
643            5,
644            &ImmConfig {
645                epsilon: 1.5,
646                ..Default::default()
647            },
648        );
649        assert!(err2.is_err());
650
651        // num_nodes = 0
652        let err3 = imm_algorithm(&adj, 0, &ImmConfig::default());
653        assert!(err3.is_err());
654    }
655
656    #[test]
657    fn test_imm_complete_graph() {
658        // 5-node complete graph: any single seed should activate all with p=1.0
659        let adj = complete_adj(5, 1.0);
660        let config = ImmConfig {
661            k: 1,
662            epsilon: 0.3,
663            delta: 0.1,
664            seed: Some(7),
665        };
666        let result = imm_algorithm(&adj, 5, &config).expect("imm complete");
667        assert_eq!(result.seeds.len(), 1);
668        assert!(result.estimated_spread >= 1.0);
669    }
670
671    #[test]
672    fn test_sandwich_approximation_basic() {
673        let adj = star_adj(6, 1.0);
674        let config = RISConfig {
675            num_rr_sets: 100,
676            seed: Some(99),
677        };
678        let (lower_seeds, lower_spread, upper_seeds, upper_spread) =
679            sandwich_approximation(&adj, 6, 1, &config).expect("sandwich");
680        assert_eq!(lower_seeds.len(), 1);
681        assert_eq!(upper_seeds.len(), 1);
682        // Both spreads should be positive
683        assert!(lower_spread >= 0.0);
684        assert!(upper_spread >= 0.0);
685        // Upper bound should not be below lower (with same seed set quality)
686        // (Note: not strictly guaranteed but should hold statistically with p=1)
687        let _ = (lower_spread, upper_spread); // suppress unused warning
688    }
689
690    #[test]
691    fn test_sandwich_k0_returns_empty() {
692        let adj = star_adj(5, 1.0);
693        let (ls, lsp, us, usp) =
694            sandwich_approximation(&adj, 5, 0, &RISConfig::default()).expect("k=0");
695        assert!(ls.is_empty());
696        assert!(us.is_empty());
697        assert_eq!(lsp, 0.0);
698        assert_eq!(usp, 0.0);
699    }
700
701    #[test]
702    fn test_sandwich_invalid_params() {
703        let adj = star_adj(5, 1.0);
704        let err = sandwich_approximation(&adj, 5, 10, &RISConfig::default());
705        assert!(err.is_err());
706        let err2 = sandwich_approximation(&adj, 0, 1, &RISConfig::default());
707        assert!(err2.is_err());
708    }
709}