Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_social/src/graph.rs

53.3 KiB, 210 runs

created by r1870400018:8956, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Social network graph generator using stub matching algorithm.
2//!
3//! This module generates realistic social networks with configurable
4//! population profiles, social circles, and geographic distributions.
5
6use crate::{
7 mmap_graph::{
8 MmapGraph,
9 MmapGraphBuilder,
10 },
11 person::{
12 PersonId,
13 ProfileType,
14 },
15};
16
17use oxedyne_fe2o3_core::{
18 prelude::*,
19 mem::get_memory_usage_mb,
20 rand::{
21 Rand,
22 SamplingMethod,
23 },
24};
25use oxedyne_fe2o3_data::digraph::{
26 LinkData,
27 NodeData,
28};
29
30use std::{
31 collections::HashMap,
32 fmt,
33 path::Path,
34};
35
36
37#[derive(Clone, Copy, Debug)]
38pub enum GraphAccessMethod {
39 Auto(usize),// Decide based on edge count.
40 FileIO, // Always use file I/O.
41 Mmap, // Always use memory mapping.
42}
43
44/// Social graph - edges only, stored in memory-mapped file.
45pub struct SocialGraph {
46 edges: MmapGraph,
47 population: u32,
48}
49
50impl SocialGraph {
51
52 pub fn new(
53 edges: MmapGraph,
54 population: u32,
55 )
56 -> Self
57 {
58 Self { edges, population }
59 }
60
61 /// Release memory pages used by the memory-mapped graph.
62 /// This tells the OS it can free cached pages to reduce memory usage.
63 #[cfg(unix)]
64 pub fn release_memory(&self) {
65 self.edges.release_memory();
66 }
67
68 #[cfg(not(unix))]
69 pub fn release_memory(&self) {
70 // No-op on non-Unix systems.
71 }
72
73 pub fn get_links_from(&self, id: &PersonId) -> Vec<(PersonId, SocialLink)> {
74 self.get_links_from_with_method(id, GraphAccessMethod::Auto(1000))
75 }
76
77 /// Gets outgoing links from a node.
78 pub fn get_links_from_with_method(
79 &self,
80 id: &PersonId,
81 method: GraphAccessMethod,
82 )
83 -> Vec<(PersonId, SocialLink)>
84 {
85 // Query the mmap file for edges from this node.
86 match self.edges.get_outgoing_edges(id.0, method) {
87 Ok(edge_list) => {
88 edge_list.into_iter().map(|(target_id, link_data)| {
89 let person_id = PersonId(target_id);
90 let social_link = SocialLink { packed: link_data };
91 (person_id, social_link)
92 }).collect()
93 },
94 Err(_) => Vec::new(),
95 }
96 }
97
98 /// Gets incoming links to a node.
99 /// Note: This is expensive (O(n)) for memory-mapped storage as it scans all edges.
100 pub fn get_links_to(&self, id: &PersonId) -> Vec<(PersonId, SocialLink)> {
101 match self.edges.get_incoming_edges(id.0) {
102 Ok(edge_list) => {
103 edge_list.into_iter().map(|(source_id, link_data)| {
104 let person_id = PersonId(source_id);
105 let social_link = SocialLink { packed: link_data };
106 (person_id, social_link)
107 }).collect()
108 },
109 Err(_) => Vec::new(),
110 }
111 }
112
113 /// Gets the total number of edges in the graph.
114 pub fn edge_count(&self) -> usize {
115 self.edges.total_edges()
116 }
117
118 /// Gets the number of nodes.
119 pub fn len(&self) -> usize {
120 self.population as usize
121 }
122
123 /// Iterator over nodes.
124 pub fn iter_nodes(&self) -> std::iter::Map<std::ops::Range<u32>, fn(u32) -> (PersonId, EmptyNodeData)> {
125 let node_count = self.population as u32;
126 (0..node_count).map(|i| (PersonId(i), EmptyNodeData))
127 }
128}
129
130/// Type of social circle relationship.
131///
132/// Represents a numbered circle from 0 (innermost) to n-1 (outermost).
133#[derive(Clone, Debug, Copy, PartialEq, Eq)]
134pub struct CircleType(pub u8);
135
136impl CircleType {
137 /// Converts circle type to matrix index.
138 ///
139 /// # Returns
140 /// Index for use in reciprocity matrix.
141 pub fn to_index(&self) -> usize {
142 self.0 as usize
143 }
144
145 /// Creates circle type from matrix index.
146 ///
147 /// # Arguments
148 /// * `idx` - Matrix index.
149 /// * `max_circles` - Maximum number of circles.
150 ///
151 /// # Returns
152 /// Corresponding circle type or error if invalid.
153 pub fn from_index(idx: usize, max_circles: usize) -> Outcome<Self> {
154 if idx >= max_circles || idx > 255 {
155 Err(err!(
156 "Invalid circle index: {} (max: {})", idx, max_circles - 1;
157 Invalid, Index
158 ))
159 } else {
160 Ok(Self(idx as u8))
161 }
162 }
163
164 /// Creates an inner circle (index 0).
165 pub fn inner() -> Self {
166 Self(0)
167 }
168
169 /// Creates a close circle (index 1).
170 pub fn close() -> Self {
171 Self(1)
172 }
173
174 /// Creates an active circle (index 2).
175 pub fn active() -> Self {
176 Self(2)
177 }
178
179 /// Creates a wider circle (index 3).
180 pub fn wider() -> Self {
181 Self(3)
182 }
183}
184
185/// Data stored on each social link.
186///
187/// Compact representation using a single byte to store both circle types.
188/// Lower 4 bits: from_circle, Upper 4 bits: to_circle.
189#[derive(Clone, Debug, Copy)]
190pub struct SocialLink {
191 pub packed: u8,
192}
193
194impl SocialLink {
195 /// Creates a new social link.
196 ///
197 /// # Arguments
198 /// * `from_circle` - Source circle type.
199 /// * `to_circle` - Target circle type.
200 ///
201 /// # Returns
202 /// New social link instance.
203 pub fn new(
204 from_circle: CircleType,
205 to_circle: CircleType,
206 )
207 -> Self
208 {
209 let packed = (to_circle.0 << 4) | (from_circle.0 & 0x0F);
210 Self { packed }
211 }
212
213 /// Gets the source circle.
214 pub fn from_circle(&self) -> CircleType {
215 CircleType(self.packed & 0x0F)
216 }
217
218 /// Gets the target circle.
219 pub fn to_circle(&self) -> CircleType {
220 CircleType(self.packed >> 4)
221 }
222}
223
224impl fmt::Display for SocialLink {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 write!(f, "C{} -> C{}", self.from_circle().0, self.to_circle().0)
227 }
228}
229
230impl LinkData for SocialLink {}
231
232/// Empty node data for edge-only graphs.
233#[derive(Clone, Debug)]
234pub struct EmptyNodeData;
235
236impl NodeData for EmptyNodeData {}
237
238impl fmt::Display for EmptyNodeData {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 write!(f, "()")
241 }
242}
243
244/// Profile definition with circle size ranges.
245#[derive(Clone, Debug)]
246pub struct Profile {
247 pub profile_type: ProfileType,
248 pub probability: f32,
249 pub circle_ranges: Vec<(u32, u32)>, // (min, max) for each circle.
250 pub sampling_methods: Vec<SamplingMethod>, // One per social circle.
251}
252
253/// Link generation mode for network creation.
254///
255/// # Example
256/// ```no_run
257/// use oxedyne_fe2o3_social::graph::{NetworkConfig, LinkMode, generate_social_network};
258///
259/// let mut config = NetworkConfig::default();
260/// config.population = 5;
261///
262/// // Create reciprocal network (default) - inverted circle relationships
263/// config.link_mode = LinkMode::Reciprocal;
264/// let reciprocal_graph = generate_social_network(config.clone()).unwrap();
265///
266/// // Create symmetric network - identical circle relationships
267/// config.link_mode = LinkMode::Symmetric;
268/// let symmetric_graph = generate_social_network(config.clone()).unwrap();
269///
270/// // Create non-reciprocal network
271/// config.link_mode = LinkMode::NonReciprocal;
272/// let non_reciprocal_graph = generate_social_network(config).unwrap();
273/// ```
274#[derive(Clone, Debug, Copy)]
275pub enum LinkMode {
276 /// All links are reciprocal - if A connects to B, B also connects to A.
277 /// Uses the existing probability matrix to determine circle types.
278 Reciprocal,
279 /// All links are symmetric - both people put each other in the same circle.
280 /// If the relationship determines circle Cx, both A→B and B→A are [Cx → Cx].
281 Symmetric,
282 /// Links are non-reciprocal - connections are one-way only.
283 /// Uses the existing probability matrix to determine circle types.
284 NonReciprocal,
285}
286
287/// Internal stub representation for matching.
288#[derive(Clone, Debug)]
289struct Stub {
290 owner_id: PersonId,
291 circle_type: CircleType,
292}
293
294/// Labels for default circle types.
295#[derive(Clone, Debug)]
296pub struct CircleLabels {
297 pub labels: Vec<String>,
298}
299
300impl CircleLabels {
301 /// Creates default circle labels.
302 pub fn default() -> Self {
303 Self {
304 labels: vec![
305 fmt!("Inner"),
306 fmt!("Close"),
307 fmt!("Active"),
308 fmt!("Wider"),
309 ],
310 }
311 }
312}
313
314
315/// Configuration for social network generation.
316#[derive(Clone)]
317pub struct NetworkConfig {
318 pub population: u32,
319 pub profiles: Vec<Profile>,
320 pub num_circles: usize,
321 pub reciprocity_matrix: Vec<Vec<f32>>, // NxN matrix for circle reciprocity.
322 pub circle_labels: Option<CircleLabels>, // Optional labels for circles.
323 pub link_mode: LinkMode, // Whether links are reciprocal or not.
324 pub progress_interval: Option<u32>, // Report progress every N nodes (None = no progress reports).
325 pub memory_limit_mb: Option<f32>, // Memory limit in MB (None = no limit).
326 pub chunk_size: Option<u32>, // Process stubs in chunks of this size (None = process all at once).
327 pub use_mmap: Option<String>, // Use memory-mapped graph with specified file path (required).
328}
329
330impl NetworkConfig {
331 /// Creates a default configuration with isolated/connected profiles.
332 ///
333 /// Returns a configuration with realistic social network parameters
334 /// including two profile types and geographic distribution.
335 /// Uses 4 circles with labels: Inner, Close, Active, Wider.
336 pub fn default() -> Self {
337 Self {
338 population: 1000,
339 profiles: vec![
340 Profile {
341 profile_type: ProfileType::Isolated,
342 probability: 0.33,
343 circle_ranges: vec![
344 (1, 1), // Inner circle.
345 (3, 3), // Close circle.
346 (5, 5), // Active circle.
347 (30, 35), // Wider circle.
348 ],
349 sampling_methods: vec![SamplingMethod::Uniform; 4],
350 },
351 Profile {
352 profile_type: ProfileType::Connected,
353 probability: 0.67,
354 circle_ranges: vec![
355 (4, 6), // Inner circle.
356 (20, 25), // Close circle.
357 (70, 75), // Active circle.
358 (450, 500), // Wider circle.
359 ],
360 sampling_methods: vec![SamplingMethod::Uniform; 4],
361 },
362 ],
363 num_circles: 4,
364 reciprocity_matrix: vec![
365 vec![0.95, 0.05, 0.00, 0.00], // Inner -> x.
366 vec![0.30, 0.50, 0.20, 0.00], // Close -> x.
367 vec![0.10, 0.40, 0.40, 0.10], // Active -> x.
368 vec![0.00, 0.10, 0.30, 0.60], // Wider -> x.
369 ],
370 circle_labels: Some(CircleLabels::default()),
371 link_mode: LinkMode::Reciprocal,
372 progress_interval: None,
373 memory_limit_mb: None,
374 chunk_size: None,
375 use_mmap: None,
376 }
377 }
378}
379
380/// Statistics from graph verification.
381#[derive(Debug)]
382pub struct GraphStatistics {
383 pub population: u32,
384 pub profile_counts: HashMap<ProfileType, usize>,
385 pub avg_circle_sizes: Vec<f32>, // Average circle sizes by type.
386}
387
388/// Generates a social network graph using the stub matching algorithm.
389///
390/// Creates a directed graph representing social relationships between
391/// people based on profile types and geographic distribution.
392/// Uses memory-mapped storage for efficient handling of large graphs.
393///
394/// # Arguments
395/// * `config` - Network generation configuration (must include mmap path).
396///
397/// # Returns
398/// A memory-mapped social graph or error if generation fails.
399pub fn generate_social_network(
400 config: NetworkConfig,
401)
402 -> Outcome<SocialGraph>
403{
404 // Validate profile sampling methods match num_circles.
405 for profile in &config.profiles {
406 if profile.sampling_methods.len() != config.num_circles {
407 return Err(err!(
408 "Profile sampling_methods length ({}) must match num_circles ({})",
409 profile.sampling_methods.len(),
410 config.num_circles;
411 Invalid, Input
412 ));
413 }
414 if profile.circle_ranges.len() != config.num_circles {
415 return Err(err!(
416 "Profile circle_ranges length ({}) must match num_circles ({})",
417 profile.circle_ranges.len(),
418 config.num_circles;
419 Invalid, Input
420 ));
421 }
422 }
423
424 // Memory-mapped storage is required.
425 let mmap_path = match config.use_mmap.clone() {
426 Some(path) => path,
427 None => return Err(err!("Memory-mapped path is required for graph generation"; Invalid, Input)),
428 };
429
430 generate_mmap_social_network(config, mmap_path)
431}
432
433/// Generates social network using memory-mapped storage.
434fn generate_mmap_social_network(
435 config: NetworkConfig,
436 mmap_path: String,
437)
438 -> Outcome<SocialGraph>
439{
440 // Check if the mmap file already exists and has content.
441 if Path::new(&mmap_path).exists() {
442 if let Ok(metadata) = std::fs::metadata(&mmap_path) {
443 if metadata.len() > 0 {
444 if let Some(_interval) = config.progress_interval {
445 info!(">>> Loading existing memory-mapped social graph");
446 info!("Population: {}", config.population);
447 info!("Memory-mapped file: {}", mmap_path);
448 info!("File size: {:.1} MB", metadata.len() as f32 / (1024.0 * 1024.0));
449 }
450
451 // Load existing mmap graph.
452 let edges = res!(MmapGraph::load_existing(&mmap_path));
453
454 return Ok(SocialGraph::new(edges, config.population));
455 }
456 }
457 }
458
459 if let Some(interval) = config.progress_interval {
460 info!(">>> Memory-mapped social graph generation");
461 info!("Population: {}", config.population);
462 info!("Progress reporting every {} nodes", interval);
463 info!("Memory-mapped file: {}", mmap_path);
464 }
465
466 // Step 1: Generate stubs directly from population range.
467 if config.progress_interval.is_some() {
468 info!("Step 1: Generating stubs for {} nodes...", config.population);
469 }
470 let stubs = create_stubs(&config);
471 // For reciprocal/symmetric modes, each stub pair creates 2 edges (A→B and B→A).
472 // For non-reciprocal mode, each pair creates 1 edge.
473 // Add 10% safety margin for edge case variations.
474 let base_edges = match config.link_mode {
475 LinkMode::NonReciprocal => stubs.len() / 2,
476 _ => stubs.len(), // Reciprocal and Symmetric create 2 edges per pair
477 };
478 let estimated_edges = (base_edges as f32 * 1.1) as usize;
479
480 if config.progress_interval.is_some() {
481 info!("Step 2: Creating memory-mapped graph (estimated {} edges)...", estimated_edges);
482 }
483
484 // Create memory-mapped graph builder.
485 // For large populations (>100k), disable indexing to save memory during generation.
486 // Smaller populations use disk-based indexing for faster lookups.
487 let max_node_id = (config.population - 1) as u32; // Node IDs are 0-based.
488 let mut builder = if config.population > 100_000 {
489 if config.progress_interval.is_some() {
490 info!("Large population detected ({}), disabling index to save memory", config.population);
491 }
492 res!(MmapGraphBuilder::new_without_index(&mmap_path, estimated_edges))
493 } else {
494 if config.progress_interval.is_some() {
495 info!("Using disk-based index for fast lookups");
496 }
497 res!(MmapGraphBuilder::new(&mmap_path, estimated_edges, max_node_id))
498 };
499
500 // Step 3: Match stubs and write directly to memory-mapped file.
501 if config.progress_interval.is_some() {
502 let total_stubs = stubs.len();
503 info!("Step 3: Matching {} stubs and writing to mmap file...", total_stubs);
504 }
505
506 let total_edges = res!(match_stubs_and_insert_to_mmap(
507 &mut builder,
508 stubs,
509 &config.reciprocity_matrix,
510 config.num_circles,
511 config.link_mode,
512 config.progress_interval,
513 config.memory_limit_mb,
514 config.chunk_size
515 ));
516
517 // Finalise graph (this will create the disk-based index if enabled).
518 let mmap_graph = res!(builder.build());
519
520
521 if config.progress_interval.is_some() {
522 let memory_mb = get_memory_usage_mb();
523 info!("Memory-mapped social network complete: {} nodes, {} edges | Memory: {:.1}MB",
524 config.population, total_edges, memory_mb);
525 }
526
527 Ok(SocialGraph::new(mmap_graph, config.population))
528}
529
530/// Matches stubs and inserts edges directly into memory-mapped graph.
531fn match_stubs_and_insert_to_mmap(
532 builder: &mut MmapGraphBuilder,
533 stubs: Vec<Stub>,
534 reciprocity_matrix: &Vec<Vec<f32>>,
535 num_circles: usize,
536 link_mode: LinkMode,
537 progress_interval: Option<u32>,
538 memory_limit_mb: Option<f32>,
539 chunk_size: Option<u32>,
540)
541 -> Outcome<usize>
542{
543 // Check initial memory usage.
544 let initial_memory = get_memory_usage_mb();
545 if let Some(limit) = memory_limit_mb {
546 if initial_memory > limit {
547 return Err(err!("Memory usage ({:.1}MB) already exceeds limit ({:.1}MB)",
548 initial_memory, limit; Invalid, Input));
549 }
550 }
551
552 // Use chunked processing for memory efficiency (always use chunked for mmap).
553 let effective_chunk_size = chunk_size.unwrap_or_else(|| {
554 // Auto-calculate chunk size based on memory constraints.
555 if let Some(limit) = memory_limit_mb {
556 // Estimate: aim to use at most 60% of memory limit for stubs.
557 let available_mb = limit * 0.6;
558 let bytes_per_stub = std::mem::size_of::<Stub>() as f32;
559 let stubs_per_mb = 1_048_576.0 / bytes_per_stub;
560 (available_mb * stubs_per_mb) as u32
561 } else {
562 // Default chunk size: 10k stubs for mmap (smaller chunks).
563 10_000
564 }
565 });
566
567 if progress_interval.is_some() {
568 info!("Using chunked processing for mmap: {} stubs per chunk", effective_chunk_size);
569 }
570
571 match_stubs_chunked_mmap(
572 builder,
573 stubs,
574 reciprocity_matrix,
575 num_circles,
576 link_mode,
577 progress_interval,
578 memory_limit_mb,
579 effective_chunk_size
580 )
581}
582
583/// Matches stubs in chunks and writes directly to memory-mapped graph.
584fn match_stubs_chunked_mmap(
585 builder: &mut MmapGraphBuilder,
586 mut stubs: Vec<Stub>,
587 reciprocity_matrix: &Vec<Vec<f32>>,
588 num_circles: usize,
589 link_mode: LinkMode,
590 progress_interval: Option<u32>,
591 memory_limit_mb: Option<f32>,
592 chunk_size: u32,
593)
594 -> Outcome<usize>
595{
596 let mut total_edges = 0;
597 let initial_stubs = stubs.len();
598 let chunk_size = chunk_size as usize;
599 let total_chunks = (initial_stubs + chunk_size - 1) / chunk_size;
600
601 // Shuffle all stubs first for better randomization.
602 shuffle_stubs(&mut stubs);
603
604 if progress_interval.is_some() {
605 info!("Processing {} stubs in {} chunks of size {}", initial_stubs, total_chunks, chunk_size);
606 }
607
608 let mut chunk_num = 0;
609 while !stubs.is_empty() {
610 chunk_num += 1;
611
612 // Extract chunk from the end of the vector.
613 let current_chunk_size = chunk_size.min(stubs.len());
614 let chunk_start = stubs.len() - current_chunk_size;
615 let chunk: Vec<Stub> = stubs.drain(chunk_start..).collect();
616
617 // Check memory usage before processing chunk.
618 let current_memory = get_memory_usage_mb();
619 if let Some(limit) = memory_limit_mb {
620 if current_memory > limit {
621 return Err(err!("Memory usage ({:.1}MB) exceeds limit ({:.1}MB) at chunk {}/{}",
622 current_memory, limit, chunk_num, total_chunks; Invalid, Input));
623 }
624 }
625
626 if let Some(_progress_interval) = progress_interval {
627 // Report more frequently for large numbers of chunks to provide better visibility
628 let report_interval = if total_chunks > 1000 {
629 std::cmp::max(1, total_chunks / 200) // Report ~200 times total for large jobs
630 } else {
631 10 // Original: every 10 chunks for smaller jobs
632 };
633
634 if chunk_num % report_interval == 1 || chunk_num == total_chunks {
635 let percent_complete = (chunk_num as f32 / total_chunks as f32) * 100.0;
636 info!("Processing chunk {}/{} ({:.1}%) | {} stubs | Memory: {:.1}MB | {} edges so far",
637 chunk_num, total_chunks, percent_complete, chunk.len(), current_memory, total_edges);
638 }
639 }
640
641 // Process this chunk and insert edges directly into mmap builder.
642 let chunk_edges = res!(match_stubs_simple_mmap(
643 builder,
644 chunk,
645 reciprocity_matrix,
646 num_circles,
647 link_mode
648 ));
649
650 total_edges += chunk_edges;
651
652 // Periodic memory check during processing.
653 if chunk_num % 50 == 0 {
654 let current_memory = get_memory_usage_mb();
655 if let Some(limit) = memory_limit_mb {
656 if current_memory > limit * 0.9 {
657 if progress_interval.is_some() {
658 info!("WARNING: Memory usage ({:.1}MB) approaching limit ({:.1}MB)",
659 current_memory, limit);
660 }
661 }
662 }
663 }
664 }
665
666 if progress_interval.is_some() {
667 let final_memory = get_memory_usage_mb();
668 info!("Chunked stub matching to mmap complete: {} edges created | Memory: {:.1}MB",
669 total_edges, final_memory);
670 }
671
672 Ok(total_edges)
673}
674
675/// Filter for selecting which nodes to dump.
676#[derive(Clone, Debug)]
677pub enum NodeFilter {
678 /// Dump all nodes.
679 All,
680 /// Dump nodes with IDs in the specified range (inclusive).
681 Range(std::ops::RangeInclusive<usize>),
682 /// Dump only nodes with specific IDs.
683 Indices(Vec<usize>),
684}
685
686impl NodeFilter {
687 /// Checks if a node ID passes the filter.
688 fn matches(&self, id: usize) -> bool {
689 match self {
690 NodeFilter::All => true,
691 NodeFilter::Range(range) => range.contains(&id),
692 NodeFilter::Indices(indices) => indices.contains(&id),
693 }
694 }
695}
696
697impl From<std::ops::RangeInclusive<usize>> for NodeFilter {
698 fn from(range: std::ops::RangeInclusive<usize>) -> Self {
699 NodeFilter::Range(range)
700 }
701}
702
703impl From<Vec<usize>> for NodeFilter {
704 fn from(indices: Vec<usize>) -> Self {
705 NodeFilter::Indices(indices)
706 }
707}
708
709impl From<&[usize]> for NodeFilter {
710 fn from(indices: &[usize]) -> Self {
711 NodeFilter::Indices(indices.to_vec())
712 }
713}
714
715/// Dumps the graph in a human-readable format.
716///
717/// Displays each node with its ID, name, and all incoming/outgoing links
718/// formatted to show circle relationships clearly.
719///
720/// # Arguments
721/// * `graph` - The social network graph to display.
722/// * `filter` - Optional filter to select which nodes to dump.
723///
724/// # Returns
725/// A formatted string representation of the graph.
726///
727/// # Example
728/// ```no_run
729/// use oxedyne_fe2o3_social::graph::{NetworkConfig, generate_social_network, dump_graph, NodeFilter};
730///
731/// let mut config = NetworkConfig::default();
732/// config.population = 10; // Small network for display.
733/// let graph = generate_social_network(config).unwrap();
734///
735/// // Dump all nodes.
736/// let dump_all = dump_graph(&graph, None);
737///
738/// // Dump nodes 0-4.
739/// let dump_range = dump_graph(&graph, Some(NodeFilter::Range(0..=4)));
740///
741/// // Dump specific nodes.
742/// let dump_specific = dump_graph(&graph, Some(NodeFilter::Indices(vec![1, 3, 5])));
743///
744/// println!("{}", dump_all); // Shows nodes with hex IDs and circle connections.
745/// ```
746pub fn dump_graph(
747 graph: &SocialGraph,
748 filter: Option<NodeFilter>,
749)
750 -> String
751{
752 let mut output = String::new();
753
754 // Use provided filter or default to All.
755 let filter = filter.unwrap_or(NodeFilter::All);
756
757 // Get all nodes and sort by ID.
758 let mut nodes: Vec<_> = graph.iter_nodes()
759 .filter(|(id, _)| filter.matches(id.0 as usize))
760 .collect();
761 nodes.sort_by_key(|(id, _)| id.0);
762
763 for (id, _data) in nodes {
764 // Format node ID in hex.
765 output.push_str(&format!("Node 0x{:04x}\n", id.0));
766
767 // Get incoming links.
768 let incoming = graph.get_links_to(&id);
769 if !incoming.is_empty() {
770 output.push_str(" Incoming:\n");
771 for (from_id, link) in incoming {
772 output.push_str(&format!(
773 " <- 0x{:04x} [{}]\n",
774 from_id.0,
775 link
776 ));
777 }
778 }
779
780 // Get outgoing links.
781 let outgoing = graph.get_links_from(&id);
782 if !outgoing.is_empty() {
783 output.push_str(" Outgoing:\n");
784 for (to_id, link) in outgoing {
785 output.push_str(&format!(
786 " -> 0x{:04x} [{}]\n",
787 to_id.0,
788 link
789 ));
790 }
791 }
792
793 output.push_str("\n");
794 }
795
796 output
797}
798
799/// Verifies that the generated graph matches the configuration specifications.
800///
801/// Calculates graph statistics and checks that they align with the
802/// expected values from the NetworkConfig.
803///
804/// # Arguments
805/// * `graph` - The generated social network graph.
806/// * `config` - The configuration used to generate the graph.
807///
808/// # Returns
809/// Graph statistics and verification results.
810pub fn verify_graph(
811 graph: &SocialGraph,
812 config: &NetworkConfig,
813)
814 -> Outcome<GraphStatistics>
815{
816 let population = config.population;
817 let edge_count = graph.edge_count();
818
819 if graph.len() == 0 && population > 0 {
820 return Err(err!(
821 "Graph is empty but config specifies {} nodes", population;
822 Invalid, Configuration
823 ));
824 }
825
826 // Verification logic.
827 let mut circle_counts = vec![0usize; config.num_circles];
828 let mut total_edges_sampled = 0;
829 let sample_size = (population / 10).max(1).min(100);
830
831 for i in 0..sample_size {
832 let node_id = PersonId(i as u32);
833 let outgoing = graph.get_links_from(&node_id);
834 for (_target, link) in outgoing {
835 let from_circle = link.from_circle().0 as usize;
836 let to_circle = link.to_circle().0 as usize;
837 if from_circle < config.num_circles {
838 circle_counts[from_circle] += 1;
839 }
840 if to_circle < config.num_circles {
841 circle_counts[to_circle] += 1;
842 }
843 total_edges_sampled += 1;
844 }
845 }
846
847 let avg_circle_sizes: Vec<f32> = circle_counts
848 .iter()
849 .map(|&count| {
850 if total_edges_sampled > 0 {
851 count as f32 / total_edges_sampled as f32
852 } else {
853 0.0
854 }
855 })
856 .collect();
857
858 let mut profile_counts = HashMap::new();
859 for profile in &config.profiles {
860 let estimated_count = (profile.probability * population as f32) as usize;
861 profile_counts.insert(profile.profile_type, estimated_count);
862 }
863
864 let expected_min_edges = population as usize / 10;
865 let expected_max_edges = population as usize * 1000;
866
867 if edge_count < expected_min_edges {
868 return Err(err!(
869 "Too few edges: {} (expected at least {} for {} nodes)",
870 edge_count, expected_min_edges, population;
871 Invalid, Configuration
872 ));
873 }
874
875 if edge_count > expected_max_edges {
876 return Err(err!(
877 "Too many edges: {} (expected at most {} for {} nodes)",
878 edge_count, expected_max_edges, population;
879 Invalid, Configuration
880 ));
881 }
882
883 Ok(GraphStatistics {
884 population,
885 profile_counts,
886 avg_circle_sizes,
887 })
888}
889
890/// Creates stubs for population using multi-profile sampling.
891///
892/// Generates connection stubs for the matching algorithm by sampling
893/// each node's profile probabilistically and then sampling circle sizes
894/// using per-profile Gaussian parameters.
895///
896/// # Arguments
897/// * `config` - Network configuration with profiles and population.
898///
899/// # Returns
900/// Vector of stubs for matching.
901fn create_stubs(config: &NetworkConfig) -> Vec<Stub> {
902 create_multiprofile_stubs(config)
903}
904
905
906/// Creates stubs using multi-profile sampling.
907fn create_multiprofile_stubs(config: &NetworkConfig) -> Vec<Stub> {
908 let mut stubs = Vec::new();
909
910 for i in 0..config.population as usize {
911 let id = PersonId(i as u32);
912 let profile = match sample_profile(&config.profiles) {
913 Ok(p) => p,
914 Err(_) => {
915 if config.profiles.is_empty() {
916 continue;
917 }
918 &config.profiles[0]
919 }
920 };
921
922 for (circle_idx, &(min_size, max_size)) in profile.circle_ranges.iter().enumerate() {
923 let circle_type = CircleType(circle_idx as u8);
924 let sampling_method = profile.sampling_methods.get(circle_idx)
925 .copied()
926 .unwrap_or(SamplingMethod::Uniform);
927
928 let size = Rand::sample_u32(
929 min_size,
930 max_size,
931 sampling_method
932 ).unwrap_or(min_size);
933
934 for _ in 0..size {
935 stubs.push(Stub {
936 owner_id: id,
937 circle_type,
938 });
939 }
940 }
941 }
942
943 stubs
944}
945
946/// Samples a profile based on probabilities.
947fn sample_profile<'a>(profiles: &'a [Profile]) -> Outcome<&'a Profile> {
948 let roll = Rand::value::<f32>();
949 let mut cumulative = 0.0;
950
951 for profile in profiles {
952 cumulative += profile.probability;
953 if roll <= cumulative {
954 return Ok(profile);
955 }
956 }
957
958 // Should not reach here if probabilities sum to 1.0.
959 Err(err!("Profile probabilities do not sum to 1.0"; Invalid, Configuration))
960}
961
962/// Simple stub matching that writes directly to memory-mapped builder.
963fn match_stubs_simple_mmap(
964 builder: &mut MmapGraphBuilder,
965 mut chunk: Vec<Stub>,
966 reciprocity_matrix: &Vec<Vec<f32>>,
967 num_circles: usize,
968 link_mode: LinkMode,
969)
970 -> Outcome<usize>
971{
972 let mut edges_created = 0;
973
974 // Process pairs from this chunk.
975 while chunk.len() >= 2 {
976 let stub_a = match chunk.pop() {
977 Some(stub) => stub,
978 None => return Err(err!("Expected stub A in chunk"; Invalid, Input)),
979 };
980 let stub_b = match chunk.pop() {
981 Some(stub) => stub,
982 None => return Err(err!("Expected stub B in chunk"; Invalid, Input)),
983 };
984
985 // Check for self-loop.
986 if stub_a.owner_id == stub_b.owner_id {
987 chunk.push(stub_a);
988 continue;
989 }
990
991 // Create and insert edges based on link mode.
992 match link_mode {
993 LinkMode::Reciprocal => {
994 // Determine reciprocal circle type using matrix.
995 let to_circle = res!(sample_reciprocal_circle(
996 stub_a.circle_type,
997 reciprocity_matrix,
998 num_circles
999 ));
1000
1001 let link_data = SocialLink::new(stub_a.circle_type, to_circle);
1002 res!(builder.add_edge(stub_a.owner_id.0, stub_b.owner_id.0, link_data.packed));
1003 edges_created += 1;
1004
1005 let reverse_link_data = SocialLink::new(to_circle, stub_a.circle_type);
1006 res!(builder.add_edge(stub_b.owner_id.0, stub_a.owner_id.0, reverse_link_data.packed));
1007 edges_created += 1;
1008 },
1009 LinkMode::Symmetric => {
1010 // In symmetric mode, both people put each other in the same circle.
1011 // Use one of the stub circle types (pick randomly between them).
1012 let symmetric_circle = if stub_a.circle_type.0 <= stub_b.circle_type.0 {
1013 stub_a.circle_type
1014 } else {
1015 stub_b.circle_type
1016 };
1017
1018 let link_data = SocialLink::new(symmetric_circle, symmetric_circle);
1019 res!(builder.add_edge(stub_a.owner_id.0, stub_b.owner_id.0, link_data.packed));
1020 edges_created += 1;
1021
1022 // Create identical symmetric link in reverse direction.
1023 res!(builder.add_edge(stub_b.owner_id.0, stub_a.owner_id.0, link_data.packed));
1024 edges_created += 1;
1025 },
1026 LinkMode::NonReciprocal => {
1027 // Determine target circle type using matrix.
1028 let to_circle = res!(sample_reciprocal_circle(
1029 stub_a.circle_type,
1030 reciprocity_matrix,
1031 num_circles
1032 ));
1033
1034 let link_data = SocialLink::new(stub_a.circle_type, to_circle);
1035 res!(builder.add_edge(stub_a.owner_id.0, stub_b.owner_id.0, link_data.packed));
1036 edges_created += 1;
1037 },
1038 }
1039 }
1040
1041 Ok(edges_created)
1042}
1043
1044/// Shuffles stubs randomly in place.
1045///
1046/// Uses Fisher-Yates shuffle algorithm for uniform randomisation.
1047///
1048/// # Arguments
1049/// * `stubs` - Mutable vector of stubs to shuffle.
1050fn shuffle_stubs(stubs: &mut Vec<Stub>) {
1051 let len = stubs.len();
1052 if len <= 1 {
1053 return;
1054 }
1055
1056 // Fisher-Yates shuffle.
1057 for i in (1..len).rev() {
1058 let j = Rand::in_range(0, i);
1059 stubs.swap(i, j);
1060 }
1061}
1062
1063/// Samples reciprocal circle type based on reciprocity matrix.
1064///
1065/// Determines what circle type the target node should use
1066/// for the reciprocal connection based on probabilities.
1067///
1068/// # Arguments
1069/// * `from_circle` - Source circle type.
1070/// * `reciprocity_matrix` - Probability matrix for reciprocity.
1071/// * `num_circles` - Number of circles in the network.
1072///
1073/// # Returns
1074/// Target circle type or error if matrix invalid.
1075fn sample_reciprocal_circle(
1076 from_circle: CircleType,
1077 reciprocity_matrix: &Vec<Vec<f32>>,
1078 num_circles: usize,
1079)
1080 -> Outcome<CircleType>
1081{
1082 let row_idx = from_circle.to_index();
1083 if row_idx >= reciprocity_matrix.len() {
1084 return Err(err!(
1085 "Circle index {} exceeds matrix size {}", row_idx, reciprocity_matrix.len();
1086 Invalid, Index
1087 ));
1088 }
1089
1090 let probabilities = &reciprocity_matrix[row_idx];
1091 let roll = Rand::value::<f32>();
1092 let mut cumulative = 0.0;
1093
1094 for (idx, &prob) in probabilities.iter().enumerate() {
1095 cumulative += prob;
1096 if roll <= cumulative {
1097 return CircleType::from_index(idx, num_circles);
1098 }
1099 }
1100
1101 // Default to outermost circle if probabilities don't sum to 1.0.
1102 Ok(CircleType((num_circles - 1) as u8))
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108
1109 #[test]
1110 fn test_generate_network() -> Outcome<()> {
1111 let mut config = NetworkConfig::default();
1112 config.use_mmap = Some("/tmp/test_generate_network.mmap".to_string());
1113 let graph = res!(generate_social_network(config.clone()));
1114
1115 // Basic validation - check edge count is reasonable for population size.
1116 let edge_count = graph.edge_count();
1117 let expected_population = config.population;
1118
1119 // Social networks typically have edge counts much higher than node counts
1120 // For our test config, we expect at least some edges per node
1121 if edge_count < expected_population / 10 {
1122 return Err(err!(
1123 "Graph has too few edges ({}) for population ({})",
1124 edge_count, expected_population;
1125 Test, Unexpected
1126 ));
1127 }
1128
1129 Ok(())
1130 }
1131
1132 #[test]
1133 fn test_circle_type_conversion() -> Outcome<()> {
1134 // Test round-trip conversion.
1135 let num_circles = 4;
1136 for i in 0..num_circles {
1137 let circle = CircleType(i as u8);
1138 let idx = circle.to_index();
1139 let converted = res!(CircleType::from_index(idx, num_circles));
1140 req!(circle, converted);
1141 }
1142
1143 // Test named constructors.
1144 req!(CircleType::inner().to_index(), 0);
1145 req!(CircleType::close().to_index(), 1);
1146 req!(CircleType::active().to_index(), 2);
1147 req!(CircleType::wider().to_index(), 3);
1148
1149 // Test invalid index.
1150 match CircleType::from_index(4, 4) {
1151 Err(_) => Ok(()),
1152 Ok(_) => Err(err!(
1153 "Should have failed for invalid index";
1154 Test, Unexpected
1155 )),
1156 }
1157 }
1158
1159 #[test]
1160 fn test_verify_graph() -> Outcome<()> {
1161 let mut config = NetworkConfig::default();
1162 config.use_mmap = Some("/tmp/test_verify_graph.mmap".to_string());
1163 let graph = res!(generate_social_network(config.clone()));
1164
1165 // Verify the graph matches configuration.
1166 let stats = res!(verify_graph(&graph, &config));
1167
1168 // Check basic statistics.
1169 req!(stats.population, config.population);
1170
1171 // Check that we have both profile types.
1172 req!(stats.profile_counts.contains_key(&ProfileType::Isolated), true);
1173 req!(stats.profile_counts.contains_key(&ProfileType::Connected), true);
1174
1175 // Check average circle sizes structure.
1176 req!(stats.avg_circle_sizes.len(), 4);
1177
1178 Ok(())
1179 }
1180
1181 fn test_config(n: usize) -> NetworkConfig {
1182 // Create a unique test file path for this population size
1183 let test_path = format!("/tmp/test_social_graph_{}.mmap", n);
1184
1185 NetworkConfig {
1186 population: n,
1187 profiles: vec![
1188 Profile {
1189 profile_type: ProfileType::Isolated,
1190 probability: 0.33,
1191 circle_ranges: vec![
1192 (1, 2), // Inner circle.
1193 (2, 3), // Close circle.
1194 (3, 4), // Active circle.
1195 (4, 5), // Wider circle.
1196 ],
1197 sampling_methods: vec![SamplingMethod::Uniform; 4],
1198 },
1199 Profile {
1200 profile_type: ProfileType::Connected,
1201 probability: 0.67,
1202 circle_ranges: vec![
1203 (2, 4), // Inner circle.
1204 (4, 6), // Close circle.
1205 (6, 8), // Active circle.
1206 (8, 10), // Wider circle.
1207 ],
1208 sampling_methods: vec![SamplingMethod::Uniform; 4],
1209 },
1210 ],
1211 num_circles: 4,
1212 reciprocity_matrix: vec![
1213 vec![0.95, 0.05, 0.00, 0.00], // Inner -> x.
1214 vec![0.30, 0.50, 0.20, 0.00], // Close -> x.
1215 vec![0.10, 0.40, 0.40, 0.10], // Active -> x.
1216 vec![0.00, 0.10, 0.30, 0.60], // Wider -> x.
1217 ],
1218 circle_labels: Some(CircleLabels::default()),
1219 link_mode: LinkMode::Symmetric,
1220 progress_interval: None,
1221 memory_limit_mb: None,
1222 chunk_size: None,
1223 use_mmap: Some(test_path), // Set memory-mapped path for tests
1224 }
1225 }
1226
1227 #[test]
1228 fn test_dump_graph() -> Outcome<()> {
1229 // Create a small test network.
1230 let config = test_config(20);
1231
1232 let graph = res!(generate_social_network(config));
1233
1234 // Test dumping all nodes.
1235 let dump_all = dump_graph(&graph, None);
1236 req!(dump_all.contains("Node 0x"), true);
1237
1238 // Test dumping a range of nodes.
1239 let dump_range = dump_graph(&graph, Some(NodeFilter::Range(0..=4)));
1240 req!(dump_range.contains("Node 0x0000"), true);
1241 req!(dump_range.contains("Node 0x0004"), true);
1242 req!(!dump_range.contains("Node 0x0005"), true);
1243
1244 // Test dumping specific nodes.
1245 let dump_specific = dump_graph(&graph, Some(NodeFilter::Indices(vec![1, 3, 5, 7])));
1246 req!(dump_specific.contains("Node 0x0001"), true);
1247 req!(dump_specific.contains("Node 0x0003"), true);
1248 req!(!dump_specific.contains("Node 0x0002"), true);
1249 req!(!dump_specific.contains("Node 0x0004"), true);
1250
1251 // Print a sample for manual inspection.
1252 println!("=== Sample dump (nodes 0-2) ===");
1253 let sample = dump_graph(&graph, Some(NodeFilter::Range(0..=2)));
1254 println!("{}", sample);
1255
1256 Ok(())
1257 }
1258
1259 #[test]
1260 fn test_dump_filter_demo() -> Outcome<()> {
1261 // Demo of different ways to use dump_graph filters.
1262 let config = test_config(10);
1263 let graph = res!(generate_social_network(config));
1264
1265 println!("=== DUMP FILTER DEMO ===");
1266
1267 // Method 1: Using None for all nodes.
1268 let all = dump_graph(&graph, None);
1269 println!("All nodes count: {}", all.matches("Node 0x").count());
1270
1271 // Method 2: Using NodeFilter enum directly.
1272 let range = dump_graph(&graph, Some(NodeFilter::Range(0..=2)));
1273 println!("\nNodes 0-2 using NodeFilter::Range:");
1274 println!("{}", range);
1275
1276 // Method 3: Using From trait with range.
1277 let range2 = dump_graph(&graph, Some((3..=5).into()));
1278 println!("Nodes 3-5 using .into():");
1279 for line in range2.lines().filter(|l| l.starts_with("Node")) {
1280 println!(" {}", line);
1281 }
1282
1283 // Method 4: Using From trait with vec.
1284 let specific = dump_graph(&graph, Some(vec![0, 5, 9].into()));
1285 println!("\nSpecific nodes [0, 5, 9]:");
1286 for line in specific.lines().filter(|l| l.starts_with("Node")) {
1287 println!(" {}", line);
1288 }
1289
1290 // Method 5: Using From trait with slice.
1291 let indices: &[usize] = &[1, 4, 7];
1292 let from_slice = dump_graph(&graph, Some(indices.into()));
1293 println!("\nFrom slice [1, 4, 7]:");
1294 for line in from_slice.lines().filter(|l| l.starts_with("Node")) {
1295 println!(" {}", line);
1296 }
1297
1298 Ok(())
1299 }
1300
1301 #[test]
1302 fn test_reciprocal_links() -> Outcome<()> {
1303 // Test reciprocal mode.
1304 let mut config = test_config(10);
1305 config.link_mode = LinkMode::Reciprocal;
1306
1307 let graph = res!(generate_social_network(config));
1308
1309 // Check that links are reciprocal.
1310 let mut reciprocal_count = 0;
1311 let mut total_edges = 0;
1312
1313 for (node_id, _) in graph.iter_nodes() {
1314 let outgoing = graph.get_links_from(&node_id);
1315 total_edges += outgoing.len();
1316
1317 for (target_id, _) in outgoing {
1318 // Check if there's a reverse link.
1319 let incoming = graph.get_links_to(&node_id);
1320 let has_reverse = incoming.iter().any(|(from_id, _)| *from_id == target_id);
1321 if has_reverse {
1322 reciprocal_count += 1;
1323 }
1324 }
1325 }
1326
1327 // In reciprocal mode, most links should be reciprocal.
1328 // Allow some tolerance since edge creation can be affected by stub counts.
1329 let reciprocal_ratio = reciprocal_count as f32 / total_edges as f32;
1330 if reciprocal_ratio < 0.7 {
1331 return Err(err!(
1332 "Reciprocal link ratio too low: {}", reciprocal_ratio;
1333 Test, Unexpected
1334 ));
1335 }
1336
1337 Ok(())
1338 }
1339
1340
1341 #[test]
1342 fn test_non_reciprocal_links() -> Outcome<()> {
1343 // Test that non-reciprocal mode produces a graph.
1344 let mut config = test_config(10);
1345 config.link_mode = LinkMode::NonReciprocal;
1346
1347 let graph = res!(generate_social_network(config));
1348
1349 // Just verify the graph was created successfully and has nodes.
1350 if graph.len() == 0 {
1351 return Err(err!(
1352 "Non-reciprocal graph should have nodes";
1353 Test, Unexpected
1354 ));
1355 }
1356
1357 // Check that some nodes have connections.
1358 let mut has_edges = false;
1359 for (node_id, _) in graph.iter_nodes() {
1360 let outgoing = graph.get_links_from(&node_id);
1361 if !outgoing.is_empty() {
1362 has_edges = true;
1363 break;
1364 }
1365 }
1366
1367 if !has_edges {
1368 return Err(err!(
1369 "Non-reciprocal graph should have edges";
1370 Test, Unexpected
1371 ));
1372 }
1373
1374 Ok(())
1375 }
1376
1377 #[test]
1378 fn test_link_mode_demo() -> Outcome<()> {
1379 // Demo showing the difference between all three link modes.
1380 let base_config = test_config(5);
1381
1382 println!("=== LINK MODE COMPARISON ===");
1383
1384 // Test Reciprocal mode.
1385 let mut reciprocal_config = base_config.clone();
1386 reciprocal_config.link_mode = LinkMode::Reciprocal;
1387 let reciprocal_graph = res!(generate_social_network(reciprocal_config));
1388 println!("\nReciprocal Mode (inverted circles):");
1389 dump_sample_connections(&reciprocal_graph, 1);
1390
1391 // Test Symmetric mode.
1392 let mut symmetric_config = base_config.clone();
1393 symmetric_config.link_mode = LinkMode::Symmetric;
1394 let symmetric_graph = res!(generate_social_network(symmetric_config));
1395 println!("\nSymmetric Mode (identical circles):");
1396 dump_sample_connections(&symmetric_graph, 1);
1397
1398 // Test Non-reciprocal mode.
1399 let mut non_reciprocal_config = base_config.clone();
1400 non_reciprocal_config.link_mode = LinkMode::NonReciprocal;
1401 let non_reciprocal_graph = res!(generate_social_network(non_reciprocal_config));
1402 println!("\nNon-Reciprocal Mode (one-way only):");
1403 dump_sample_connections(&non_reciprocal_graph, 1);
1404
1405 Ok(())
1406 }
1407
1408 // Helper function to dump sample connections from a graph.
1409 fn dump_sample_connections(graph: &SocialGraph, max_nodes: usize) {
1410 let mut count = 0;
1411 for (node_id, _) in graph.iter_nodes() {
1412 if count >= max_nodes { break; }
1413
1414 let outgoing = graph.get_links_from(&node_id);
1415 let incoming = graph.get_links_to(&node_id);
1416
1417 println!(" Node {:?}:", node_id);
1418 for (target_id, link) in &outgoing {
1419 print!(" -> {:?}: [C{} -> C{}]", target_id, link.from_circle().0, link.to_circle().0);
1420
1421 // Find reverse link if it exists.
1422 let mut found_reverse = false;
1423 for (source_id, reverse_link) in &incoming {
1424 if source_id == target_id {
1425 println!(" <-> [C{} -> C{}]", reverse_link.from_circle().0, reverse_link.to_circle().0);
1426 found_reverse = true;
1427 break;
1428 }
1429 }
1430 if !found_reverse {
1431 println!(" (one-way)");
1432 }
1433 }
1434 count += 1;
1435 }
1436 }
1437
1438 #[test]
1439 fn test_mode_comparison() -> Outcome<()> {
1440 // Test that reciprocal mode creates more reciprocal links than non-reciprocal mode.
1441
1442 // Create reciprocal network.
1443 let mut reciprocal_config = test_config(15);
1444 reciprocal_config.link_mode = LinkMode::Reciprocal;
1445 let reciprocal_graph = res!(generate_social_network(reciprocal_config));
1446
1447 // Create non-reciprocal network.
1448 let mut non_reciprocal_config = test_config(15);
1449 non_reciprocal_config.link_mode = LinkMode::NonReciprocal;
1450 let non_reciprocal_graph = res!(generate_social_network(non_reciprocal_config));
1451
1452 // Calculate reciprocal ratios for both.
1453 let calc_ratio = |graph: &SocialGraph| -> f32 {
1454 let mut reciprocal_count = 0;
1455 let mut total_edges = 0;
1456
1457 for (node_id, _) in graph.iter_nodes() {
1458 let outgoing = graph.get_links_from(&node_id);
1459 total_edges += outgoing.len();
1460
1461 for (target_id, _) in outgoing {
1462 let incoming = graph.get_links_to(&node_id);
1463 let has_reverse = incoming.iter().any(|(from_id, _)| *from_id == target_id);
1464 if has_reverse {
1465 reciprocal_count += 1;
1466 }
1467 }
1468 }
1469
1470 if total_edges > 0 {
1471 reciprocal_count as f32 / total_edges as f32
1472 } else {
1473 0.0
1474 }
1475 };
1476
1477 let reciprocal_ratio = calc_ratio(&reciprocal_graph);
1478 let non_reciprocal_ratio = calc_ratio(&non_reciprocal_graph);
1479
1480 // Reciprocal mode should have high reciprocal ratio.
1481 if reciprocal_ratio < 0.8 {
1482 return Err(err!(
1483 "Reciprocal mode ratio ({}) should be >= 0.8", reciprocal_ratio;
1484 Test, Unexpected
1485 ));
1486 }
1487
1488 // Non-reciprocal mode may have perfect reciprocity too due to stub matching algorithm.
1489 // Just verify both modes work and reciprocal is at least as good.
1490 if reciprocal_ratio < non_reciprocal_ratio {
1491 return Err(err!(
1492 "Reciprocal mode ratio ({}) should be >= non-reciprocal ratio ({})",
1493 reciprocal_ratio, non_reciprocal_ratio;
1494 Test, Unexpected
1495 ));
1496 }
1497
1498 println!("Reciprocal mode ratio: {:.2}", reciprocal_ratio);
1499 println!("Non-reciprocal mode ratio: {:.2}", non_reciprocal_ratio);
1500
1501 Ok(())
1502 }
1503
1504
1505 #[test]
1506 fn test_reciprocal_debug() -> Outcome<()> {
1507 // Debug reciprocal mode with larger network.
1508 let mut config = test_config(20);
1509 config.link_mode = LinkMode::Reciprocal;
1510
1511 let graph = res!(generate_social_network(config));
1512
1513 println!("=== RECIPROCAL DEBUG ===");
1514
1515 // Show first node's connections in detail.
1516 if let Some((first_id, _)) = graph.iter_nodes().next() {
1517 println!("Example reciprocal connections for Node 0x{:04x}:", first_id.0);
1518
1519 let outgoing = graph.get_links_from(&first_id);
1520 for (target_id, link_data) in &outgoing {
1521 let incoming = graph.get_links_to(&first_id);
1522 let reverse = incoming.iter().find(|(from_id, _)| *from_id == *target_id);
1523
1524 if let Some((_, reverse_link)) = reverse {
1525 println!(" 0x{:04x} <-> 0x{:04x}: [{}] <-> [{}]",
1526 first_id.0, target_id.0, link_data, reverse_link);
1527 } else {
1528 println!(" 0x{:04x} -> 0x{:04x}: [{}] (NO REVERSE!)",
1529 first_id.0, target_id.0, link_data);
1530 }
1531 }
1532 }
1533
1534 // Count non-reciprocal edges.
1535 let mut non_reciprocal_count = 0;
1536 let mut total_edges = 0;
1537
1538 for (node_id, _) in graph.iter_nodes() {
1539 let outgoing = graph.get_links_from(&node_id);
1540
1541 for (target_id, _) in outgoing {
1542 total_edges += 1;
1543
1544 // Check if there's a reverse link.
1545 let incoming_to_target = graph.get_links_to(&target_id);
1546 let has_reverse = incoming_to_target.iter().any(|(from_id, _)| *from_id == node_id);
1547
1548 if !has_reverse {
1549 non_reciprocal_count += 1;
1550 println!("NON-RECIPROCAL: 0x{:04x} -> 0x{:04x} has no reverse", node_id.0, target_id.0);
1551 }
1552 }
1553 }
1554
1555 println!("Non-reciprocal edges: {} / {}", non_reciprocal_count, total_edges);
1556
1557 if non_reciprocal_count > 0 {
1558 return Err(err!(
1559 "Found {} non-reciprocal edges in reciprocal mode", non_reciprocal_count;
1560 Test, Unexpected
1561 ));
1562 }
1563
1564 Ok(())
1565 }
1566
1567 #[test]
1568 fn test_sample_u32() -> Outcome<()> {
1569 // Test uniform sampling.
1570 for _ in 0..100 {
1571 let val = res!(Rand::sample_u32(10, 20, SamplingMethod::Uniform));
1572 if !(val >= 10 && val <= 20) {
1573 return Err(err!(
1574 "Uniform sample {} out of range [10, 20]", val;
1575 Test, Unexpected
1576 ));
1577 }
1578 }
1579
1580 // Test Gaussian sampling.
1581 for _ in 0..100 {
1582 let val = res!(Rand::sample_u32(50, 100, SamplingMethod::GaussianClampedDerived));
1583 if !(val >= 50 && val <= 100) {
1584 return Err(err!(
1585 "Gaussian sample {} out of range [50, 100]", val;
1586 Test, Unexpected
1587 ));
1588 }
1589 }
1590
1591 // Test invalid range.
1592 match Rand::sample_u32(20, 10, SamplingMethod::Uniform) {
1593 Err(_) => Ok(()),
1594 Ok(_) => Err(err!(
1595 "Should have failed for invalid range";
1596 Test, Unexpected
1597 )),
1598 }
1599 }
1600}