1use crate::graph::GraphData;
11use crate::leiden::{Leiden, LeidenConfig, LeidenOutput, QualityType};
12use crate::partition::Partition;
13#[cfg(feature = "rayon")]
14use rayon::prelude::*;
15
16#[derive(Debug, Clone)]
18#[non_exhaustive]
19pub struct ResolutionEntry {
20 pub resolution: f64,
22 pub num_communities: usize,
24 pub quality: f64,
26 pub partition: Partition,
28}
29
30pub fn resolution_scan(
37 data: &GraphData,
38 quality: QualityType,
39 resolution_range: (f64, f64),
40 num_points: usize,
41 seed: Option<u64>,
42) -> crate::error::Result<Vec<ResolutionEntry>> {
43 if num_points == 0 {
44 return Ok(Vec::new());
45 }
46
47 let compute_entry = |i: usize| -> crate::error::Result<ResolutionEntry> {
48 let gamma = if num_points == 1 {
49 resolution_range.0
50 } else {
51 resolution_range.0
52 + (resolution_range.1 - resolution_range.0) * (i as f64) / ((num_points - 1) as f64)
53 };
54
55 let config = LeidenConfig {
56 resolution: gamma,
57 quality,
58 seed: seed.map(|s| s + i as u64),
59 ..Default::default()
60 };
61
62 let LeidenOutput {
63 partition,
64 quality: q,
65 ..
66 } = Leiden::new(config).run(data)?;
67
68 Ok(ResolutionEntry {
69 resolution: gamma,
70 num_communities: partition.num_communities(),
71 quality: q,
72 partition,
73 })
74 };
75
76 #[cfg(feature = "rayon")]
77 let entries: Vec<ResolutionEntry> = (0..num_points)
78 .into_par_iter()
79 .map(compute_entry)
80 .collect::<crate::error::Result<Vec<_>>>()?;
81
82 #[cfg(not(feature = "rayon"))]
83 let entries: Vec<ResolutionEntry> = (0..num_points)
84 .map(compute_entry)
85 .collect::<crate::error::Result<Vec<_>>>()?;
86
87 Ok(entries)
88}
89
90pub fn resolution_profile(
101 data: &GraphData,
102 quality: QualityType,
103 resolution_range: (f64, f64),
104 seed: Option<u64>,
105 min_diff_resolution: f64,
106 min_diff_quality: f64,
107) -> crate::error::Result<Vec<ResolutionEntry>> {
108 let mut entries = Vec::new();
109 let ctx = BisectContext {
110 data,
111 quality,
112 min_diff_resolution,
113 max_depth: 50,
114 };
115 bisect(&ctx, resolution_range.0, resolution_range.1, seed.unwrap_or(0), 0, &mut entries)?;
116
117 entries.sort_by(|a, b| {
118 a.resolution
119 .partial_cmp(&b.resolution)
120 .unwrap_or(std::cmp::Ordering::Equal)
121 });
122
123 let mut deduped: Vec<ResolutionEntry> = Vec::new();
124 for entry in entries {
125 if let Some(last) = deduped.last() {
126 if last.num_communities == entry.num_communities
127 && (last.quality - entry.quality).abs() < min_diff_quality
128 {
129 continue;
130 }
131 }
132 deduped.push(entry);
133 }
134
135 Ok(deduped)
136}
137
138struct BisectContext<'a> {
139 data: &'a GraphData,
140 quality: QualityType,
141 min_diff_resolution: f64,
142 max_depth: usize,
143}
144
145fn bisect(
146 ctx: &BisectContext<'_>,
147 gamma_low: f64,
148 gamma_high: f64,
149 seed: u64,
150 depth: usize,
151 entries: &mut Vec<ResolutionEntry>,
152) -> crate::error::Result<()> {
153 let n = ctx.data.node_count();
154 if n == 0 {
155 let config = LeidenConfig {
156 resolution: gamma_low,
157 quality: ctx.quality,
158 seed: Some(seed),
159 ..Default::default()
160 };
161 let LeidenOutput {
162 partition,
163 quality: q,
164 ..
165 } = Leiden::new(config).run(ctx.data)?;
166 entries.push(ResolutionEntry {
167 resolution: gamma_low,
168 num_communities: partition.num_communities(),
169 quality: q,
170 partition,
171 });
172 return Ok(());
173 }
174
175 if depth >= ctx.max_depth {
176 let config_low = LeidenConfig {
177 resolution: gamma_low,
178 quality: ctx.quality,
179 seed: Some(seed),
180 ..Default::default()
181 };
182 let LeidenOutput {
183 partition: p_low,
184 quality: q_low,
185 ..
186 } = Leiden::new(config_low).run(ctx.data)?;
187 let config_high = LeidenConfig {
188 resolution: gamma_high,
189 quality: ctx.quality,
190 seed: Some(seed.wrapping_add(1)),
191 ..Default::default()
192 };
193 let LeidenOutput {
194 partition: p_high,
195 quality: q_high,
196 ..
197 } = Leiden::new(config_high).run(ctx.data)?;
198 let partitions_differ = !partitions_equal(&p_low, &p_high, n);
199 entries.push(ResolutionEntry {
200 resolution: gamma_low,
201 num_communities: p_low.num_communities(),
202 quality: q_low,
203 partition: p_low,
204 });
205 if partitions_differ {
206 entries.push(ResolutionEntry {
207 resolution: gamma_high,
208 num_communities: p_high.num_communities(),
209 quality: q_high,
210 partition: p_high,
211 });
212 }
213 return Ok(());
214 }
215
216 let config_low = LeidenConfig {
217 resolution: gamma_low,
218 quality: ctx.quality,
219 seed: Some(seed),
220 ..Default::default()
221 };
222 let LeidenOutput {
223 partition: p_low,
224 quality: q_low,
225 ..
226 } = Leiden::new(config_low).run(ctx.data)?;
227
228 let config_high = LeidenConfig {
229 resolution: gamma_high,
230 quality: ctx.quality,
231 seed: Some(seed.wrapping_add(1)),
232 ..Default::default()
233 };
234 let LeidenOutput {
235 partition: p_high,
236 quality: q_high,
237 ..
238 } = Leiden::new(config_high).run(ctx.data)?;
239
240 let range = gamma_high - gamma_low;
241
242 if !partitions_equal(&p_low, &p_high, n) && range > ctx.min_diff_resolution {
243 let mid = if gamma_low > 0.0 && gamma_high > 0.0 {
244 (gamma_low * gamma_high).sqrt()
245 } else {
246 (gamma_low + gamma_high) / 2.0
247 };
248
249 bisect(
250 ctx,
251 gamma_low,
252 mid,
253 seed.wrapping_add(2),
254 depth + 1,
255 entries,
256 )?;
257 bisect(
258 ctx,
259 mid,
260 gamma_high,
261 seed.wrapping_add(3),
262 depth + 1,
263 entries,
264 )?;
265 } else {
266 let partitions_differ = !partitions_equal(&p_low, &p_high, n);
267 entries.push(ResolutionEntry {
268 resolution: gamma_low,
269 num_communities: p_low.num_communities(),
270 quality: q_low,
271 partition: p_low,
272 });
273 if partitions_differ {
274 entries.push(ResolutionEntry {
277 resolution: gamma_high,
278 num_communities: p_high.num_communities(),
279 quality: q_high,
280 partition: p_high,
281 });
282 }
283 }
284
285 Ok(())
286}
287
288fn partitions_equal(p1: &Partition, p2: &Partition, n: usize) -> bool {
293 if p1.num_communities() != p2.num_communities() {
294 return false;
295 }
296
297 let norm1 = normalize_partition(p1, n);
298 let norm2 = normalize_partition(p2, n);
299 norm1 == norm2
300}
301
302fn normalize_partition(partition: &Partition, n: usize) -> Vec<usize> {
305 let mut mapping: Vec<usize> = vec![usize::MAX; n];
306 let mut next_id = 0usize;
307 let mut result = vec![0usize; n];
308
309 for (node, &comm) in partition.as_slice().iter().enumerate() {
310 if mapping[comm] == usize::MAX {
311 mapping[comm] = next_id;
312 next_id += 1;
313 }
314 result[node] = mapping[comm];
315 }
316
317 result
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::graph::GraphDataBuilder;
324
325 fn make_two_cliques() -> GraphData {
326 let mut b = GraphDataBuilder::new(10);
327 for i in 0..5 {
329 for j in (i + 1)..5 {
330 b.add_edge(i, j, 1.0).unwrap();
331 }
332 }
333 for i in 5..10 {
335 for j in (i + 1)..10 {
336 b.add_edge(i, j, 1.0).unwrap();
337 }
338 }
339 b.add_edge(0, 5, 1.0).unwrap();
341 b.build().unwrap()
342 }
343
344 #[test]
345 fn test_resolution_scan_two_cliques() {
346 let data = make_two_cliques();
347
348 let entries =
349 resolution_scan(&data, QualityType::Modularity, (0.1, 2.0), 10, Some(42)).unwrap();
350
351 assert_eq!(entries.len(), 10);
352
353 for i in 1..entries.len() {
355 assert!(entries[i].resolution > entries[i - 1].resolution);
356 }
357
358 assert!(
360 entries[0].num_communities >= 1,
361 "expected at least 1 community at low gamma, got {}",
362 entries[0].num_communities,
363 );
364
365 let has_two = entries.iter().any(|e| e.num_communities == 2);
367 assert!(has_two, "expected at least one entry with 2 communities");
368
369 for entry in &entries {
371 assert_eq!(entry.partition.len(), 10);
372 }
373 }
374
375 #[test]
376 fn test_resolution_profile_two_cliques() {
377 let data = make_two_cliques();
378
379 let entries =
380 resolution_profile(&data, QualityType::CPM, (0.1, 2.0), Some(42), 0.01, 0.001).unwrap();
381
382 assert!(
383 !entries.is_empty(),
384 "profile should produce at least one entry"
385 );
386
387 for i in 1..entries.len() {
389 assert!(
390 entries[i].resolution >= entries[i - 1].resolution,
391 "entries not sorted: {} >= {}",
392 entries[i].resolution,
393 entries[i - 1].resolution,
394 );
395 }
396
397 for entry in &entries {
399 assert!(entry.resolution >= 0.1 - 1e-10);
400 assert!(entry.resolution <= 2.0 + 1e-10);
401 }
402
403 for entry in &entries {
405 assert_eq!(entry.partition.len(), 10);
406 }
407 }
408
409 #[test]
410 fn test_resolution_scan_single_point() {
411 let data = make_two_cliques();
412
413 let entries =
414 resolution_scan(&data, QualityType::Modularity, (1.0, 2.0), 1, Some(42)).unwrap();
415
416 assert_eq!(entries.len(), 1);
417 assert!((entries[0].resolution - 1.0).abs() < 1e-10);
418 }
419
420 #[test]
421 fn test_resolution_scan_zero_points() {
422 let data = make_two_cliques();
423
424 let entries =
425 resolution_scan(&data, QualityType::Modularity, (0.1, 2.0), 0, Some(42)).unwrap();
426
427 assert!(entries.is_empty());
428 }
429
430 #[test]
431 fn test_partitions_equal_identical() {
432 let p1 = Partition::from_membership(vec![0, 0, 1, 1]);
433 let p2 = Partition::from_membership(vec![0, 0, 1, 1]);
434 assert!(partitions_equal(&p1, &p2, 4));
435 }
436
437 #[test]
438 fn test_partitions_equal_relabel() {
439 let p1 = Partition::from_membership(vec![0, 0, 1, 1]);
440 let p2 = Partition::from_membership(vec![1, 1, 0, 0]);
441 assert!(partitions_equal(&p1, &p2, 4));
442 }
443
444 #[test]
445 fn test_partitions_equal_different() {
446 let p1 = Partition::from_membership(vec![0, 0, 0, 1]);
447 let p2 = Partition::from_membership(vec![0, 0, 1, 1]);
448 assert!(!partitions_equal(&p1, &p2, 4));
449 }
450
451 #[test]
452 fn test_bisect_records_both_endpoints() {
453 let mut b = GraphDataBuilder::new(2);
454 b.add_edge(0, 1, 0.1).unwrap();
455 let g = b.build().unwrap();
456 let profile = resolution_profile(
457 &g,
458 QualityType::Modularity,
459 (0.1, 2.0),
460 Some(42),
461 1e-2,
462 1e-6,
463 )
464 .unwrap();
465 assert!(
466 profile.len() >= 2,
467 "expected at least 2 entries, got {}",
468 profile.len()
469 );
470 let eps = 1e-6;
471 assert!(
472 (profile.first().unwrap().resolution - 0.1).abs() < eps,
473 "first resolution should be 0.1, got {}",
474 profile.first().unwrap().resolution
475 );
476 assert!(
477 (profile.last().unwrap().resolution - 2.0).abs() < eps,
478 "last resolution should be 2.0, got {}",
479 profile.last().unwrap().resolution
480 );
481 }
482
483 #[test]
484 fn test_resolution_profile_deduplication() {
485 let data = make_two_cliques();
487
488 let entries = resolution_profile(
489 &data,
490 QualityType::CPM,
491 (0.5, 1.5),
492 Some(42),
493 0.01,
494 1000.0, )
496 .unwrap();
497
498 assert!(
500 entries.len() <= 3,
501 "expected heavy deduplication, got {} entries",
502 entries.len(),
503 );
504 }
505}