rustkernel_audit/
hypergraph.rs

1//! Hypergraph construction kernel for financial audit.
2//!
3//! Constructs multi-way relationship hypergraphs from audit records.
4
5use crate::types::*;
6use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
7use std::collections::{HashMap, HashSet, VecDeque};
8
9// ============================================================================
10// HypergraphConstruction Kernel
11// ============================================================================
12
13/// Hypergraph construction kernel for multi-way relationships.
14///
15/// Constructs hypergraphs where edges can connect more than two nodes,
16/// enabling analysis of complex multi-party relationships in financial data.
17#[derive(Debug, Clone)]
18pub struct HypergraphConstruction {
19    metadata: KernelMetadata,
20}
21
22impl Default for HypergraphConstruction {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl HypergraphConstruction {
29    /// Create a new hypergraph construction kernel.
30    #[must_use]
31    pub fn new() -> Self {
32        Self {
33            metadata: KernelMetadata::batch("audit/hypergraph", Domain::FinancialAudit)
34                .with_description("Multi-way relationship hypergraph")
35                .with_throughput(10_000)
36                .with_latency_us(500.0),
37        }
38    }
39
40    /// Construct hypergraph from audit records.
41    pub fn construct(records: &[AuditRecord], config: &HypergraphConfig) -> HypergraphResult {
42        // Build nodes
43        let nodes = Self::build_nodes(records, config);
44
45        // Build hyperedges
46        let edges = Self::build_edges(records, &nodes, config);
47
48        // Build node-to-edge mapping
49        let node_edges = Self::build_node_edge_mapping(&edges);
50
51        let hypergraph = Hypergraph {
52            nodes,
53            edges,
54            node_edges,
55        };
56
57        // Calculate centrality
58        let node_centrality = Self::calculate_centrality(&hypergraph);
59
60        // Calculate edge weights
61        let edge_weights = Self::calculate_edge_weights(&hypergraph);
62
63        // Detect patterns
64        let patterns = if config.detect_patterns {
65            Self::detect_patterns(&hypergraph, &node_centrality)
66        } else {
67            Vec::new()
68        };
69
70        // Calculate statistics
71        let stats = Self::calculate_stats(&hypergraph);
72
73        HypergraphResult {
74            hypergraph,
75            node_centrality,
76            edge_weights,
77            patterns,
78            stats,
79        }
80    }
81
82    /// Build nodes from audit records.
83    fn build_nodes(
84        records: &[AuditRecord],
85        config: &HypergraphConfig,
86    ) -> HashMap<String, HypergraphNode> {
87        let mut nodes = HashMap::new();
88
89        for record in records {
90            // Entity node
91            if config.include_entity_nodes {
92                let entity_id = format!("entity:{}", record.entity_id);
93                nodes
94                    .entry(entity_id.clone())
95                    .or_insert_with(|| HypergraphNode {
96                        id: entity_id,
97                        node_type: NodeType::Entity,
98                        attributes: HashMap::new(),
99                    });
100            }
101
102            // Account node
103            if config.include_account_nodes {
104                if let Some(account) = &record.account {
105                    let account_id = format!("account:{}", account);
106                    nodes
107                        .entry(account_id.clone())
108                        .or_insert_with(|| HypergraphNode {
109                            id: account_id,
110                            node_type: NodeType::Account,
111                            attributes: HashMap::new(),
112                        });
113                }
114            }
115
116            // Transaction node (optional - each record as a node)
117            if config.include_transaction_nodes {
118                let tx_id = format!("tx:{}", record.id);
119                nodes
120                    .entry(tx_id.clone())
121                    .or_insert_with(|| HypergraphNode {
122                        id: tx_id,
123                        node_type: NodeType::Transaction,
124                        attributes: HashMap::new(),
125                    });
126            }
127
128            // Category node
129            if config.include_category_nodes {
130                let cat_id = format!("category:{}", record.category);
131                nodes
132                    .entry(cat_id.clone())
133                    .or_insert_with(|| HypergraphNode {
134                        id: cat_id,
135                        node_type: NodeType::Category,
136                        attributes: HashMap::new(),
137                    });
138            }
139
140            // Time period node (bucket by day)
141            if config.include_time_nodes {
142                let day = record.timestamp / 86400;
143                let time_id = format!("day:{}", day);
144                nodes
145                    .entry(time_id.clone())
146                    .or_insert_with(|| HypergraphNode {
147                        id: time_id,
148                        node_type: NodeType::TimePeriod,
149                        attributes: HashMap::new(),
150                    });
151            }
152        }
153
154        nodes
155    }
156
157    /// Build hyperedges from records.
158    fn build_edges(
159        records: &[AuditRecord],
160        nodes: &HashMap<String, HypergraphNode>,
161        config: &HypergraphConfig,
162    ) -> Vec<Hyperedge> {
163        let mut edges = Vec::new();
164        let mut edge_id = 0;
165
166        for record in records {
167            let mut connected_nodes = Vec::new();
168
169            // Collect all nodes related to this record
170            if config.include_entity_nodes {
171                let entity_id = format!("entity:{}", record.entity_id);
172                if nodes.contains_key(&entity_id) {
173                    connected_nodes.push(entity_id);
174                }
175            }
176
177            if config.include_account_nodes {
178                if let Some(account) = &record.account {
179                    let account_id = format!("account:{}", account);
180                    if nodes.contains_key(&account_id) {
181                        connected_nodes.push(account_id);
182                    }
183                }
184            }
185
186            if config.include_transaction_nodes {
187                let tx_id = format!("tx:{}", record.id);
188                if nodes.contains_key(&tx_id) {
189                    connected_nodes.push(tx_id);
190                }
191            }
192
193            if config.include_category_nodes {
194                let cat_id = format!("category:{}", record.category);
195                if nodes.contains_key(&cat_id) {
196                    connected_nodes.push(cat_id);
197                }
198            }
199
200            if config.include_time_nodes {
201                let day = record.timestamp / 86400;
202                let time_id = format!("day:{}", day);
203                if nodes.contains_key(&time_id) {
204                    connected_nodes.push(time_id);
205                }
206            }
207
208            // Create hyperedge if we have multiple nodes
209            if connected_nodes.len() >= config.min_edge_size {
210                edge_id += 1;
211                edges.push(Hyperedge {
212                    id: format!("edge:{}", edge_id),
213                    edge_type: Self::determine_edge_type(record),
214                    nodes: connected_nodes,
215                    weight: record.amount.unwrap_or(1.0),
216                    timestamp: Some(record.timestamp),
217                    attributes: HashMap::new(),
218                });
219            }
220        }
221
222        edges
223    }
224
225    /// Determine hyperedge type based on record.
226    fn determine_edge_type(record: &AuditRecord) -> HyperedgeType {
227        match record.record_type {
228            AuditRecordType::Payment | AuditRecordType::Transfer => HyperedgeType::Transaction,
229            AuditRecordType::Invoice | AuditRecordType::Receipt => HyperedgeType::DocumentRef,
230            AuditRecordType::JournalEntry | AuditRecordType::Adjustment => {
231                HyperedgeType::AccountRel
232            }
233            AuditRecordType::Expense | AuditRecordType::Revenue => {
234                HyperedgeType::CategoryMembership
235            }
236        }
237    }
238
239    /// Build node to edge mapping.
240    fn build_node_edge_mapping(edges: &[Hyperedge]) -> HashMap<String, Vec<String>> {
241        let mut mapping: HashMap<String, Vec<String>> = HashMap::new();
242
243        for edge in edges {
244            for node_id in &edge.nodes {
245                mapping
246                    .entry(node_id.clone())
247                    .or_default()
248                    .push(edge.id.clone());
249            }
250        }
251
252        mapping
253    }
254
255    /// Calculate node centrality.
256    fn calculate_centrality(hypergraph: &Hypergraph) -> HashMap<String, f64> {
257        let mut centrality = HashMap::new();
258
259        // Degree centrality based on number of edges containing each node
260        let total_edges = hypergraph.edges.len() as f64;
261
262        for node_id in hypergraph.nodes.keys() {
263            let edge_count = hypergraph
264                .node_edges
265                .get(node_id)
266                .map(|e| e.len())
267                .unwrap_or(0) as f64;
268
269            let degree_centrality = if total_edges > 0.0 {
270                edge_count / total_edges
271            } else {
272                0.0
273            };
274
275            centrality.insert(node_id.clone(), degree_centrality);
276        }
277
278        centrality
279    }
280
281    /// Calculate edge weights (normalized).
282    fn calculate_edge_weights(hypergraph: &Hypergraph) -> HashMap<String, f64> {
283        let max_weight = hypergraph
284            .edges
285            .iter()
286            .map(|e| e.weight)
287            .fold(0.0, f64::max);
288
289        hypergraph
290            .edges
291            .iter()
292            .map(|e| {
293                let normalized = if max_weight > 0.0 {
294                    e.weight / max_weight
295                } else {
296                    0.0
297                };
298                (e.id.clone(), normalized)
299            })
300            .collect()
301    }
302
303    /// Detect patterns in hypergraph.
304    fn detect_patterns(
305        hypergraph: &Hypergraph,
306        centrality: &HashMap<String, f64>,
307    ) -> Vec<HypergraphPattern> {
308        let mut patterns = Vec::new();
309        let mut pattern_id = 0;
310
311        // Detect high centrality hubs
312        let avg_centrality: f64 = centrality.values().sum::<f64>() / centrality.len().max(1) as f64;
313        let std_centrality = Self::std_dev(&centrality.values().cloned().collect::<Vec<_>>());
314
315        for (node_id, cent) in centrality {
316            if *cent > avg_centrality + 2.0 * std_centrality {
317                pattern_id += 1;
318                patterns.push(HypergraphPattern {
319                    id: format!("pattern:{}", pattern_id),
320                    pattern_type: PatternType::HighCentralityHub,
321                    nodes: vec![node_id.clone()],
322                    edges: hypergraph
323                        .node_edges
324                        .get(node_id)
325                        .cloned()
326                        .unwrap_or_default(),
327                    confidence: (*cent - avg_centrality) / std_centrality / 3.0,
328                    description: format!(
329                        "High centrality hub detected: {} (centrality: {:.3})",
330                        node_id, cent
331                    ),
332                });
333            }
334        }
335
336        // Detect isolated components
337        let components = Self::find_connected_components(hypergraph);
338        if components.len() > 1 {
339            for (i, component) in components.iter().enumerate() {
340                if component.len() < hypergraph.nodes.len() / 10 {
341                    pattern_id += 1;
342                    patterns.push(HypergraphPattern {
343                        id: format!("pattern:{}", pattern_id),
344                        pattern_type: PatternType::IsolatedComponent,
345                        nodes: component.clone(),
346                        edges: Vec::new(),
347                        confidence: 0.8,
348                        description: format!(
349                            "Isolated component {} with {} nodes",
350                            i + 1,
351                            component.len()
352                        ),
353                    });
354                }
355            }
356        }
357
358        // Detect dense subgraphs (nodes with many shared edges)
359        let dense = Self::find_dense_subgraphs(hypergraph);
360        for (nodes, density) in dense {
361            if density > 0.5 {
362                pattern_id += 1;
363                let related_edges: Vec<String> = nodes
364                    .iter()
365                    .flat_map(|n| hypergraph.node_edges.get(n).cloned().unwrap_or_default())
366                    .collect::<HashSet<_>>()
367                    .into_iter()
368                    .collect();
369
370                patterns.push(HypergraphPattern {
371                    id: format!("pattern:{}", pattern_id),
372                    pattern_type: PatternType::DenseSubgraph,
373                    nodes,
374                    edges: related_edges,
375                    confidence: density,
376                    description: format!("Dense subgraph with density {:.2}", density),
377                });
378            }
379        }
380
381        patterns
382    }
383
384    /// Find connected components using BFS.
385    fn find_connected_components(hypergraph: &Hypergraph) -> Vec<Vec<String>> {
386        let mut components = Vec::new();
387        let mut visited: HashSet<&String> = HashSet::new();
388
389        for node_id in hypergraph.nodes.keys() {
390            if visited.contains(node_id) {
391                continue;
392            }
393
394            let mut component = Vec::new();
395            let mut queue = VecDeque::new();
396            queue.push_back(node_id);
397
398            while let Some(current) = queue.pop_front() {
399                if visited.contains(current) {
400                    continue;
401                }
402                visited.insert(current);
403                component.push(current.clone());
404
405                // Find neighbors through shared edges
406                if let Some(edges) = hypergraph.node_edges.get(current) {
407                    for edge_id in edges {
408                        if let Some(edge) = hypergraph.edges.iter().find(|e| &e.id == edge_id) {
409                            for neighbor in &edge.nodes {
410                                if !visited.contains(neighbor) {
411                                    queue.push_back(neighbor);
412                                }
413                            }
414                        }
415                    }
416                }
417            }
418
419            if !component.is_empty() {
420                components.push(component);
421            }
422        }
423
424        components
425    }
426
427    /// Find dense subgraphs.
428    fn find_dense_subgraphs(hypergraph: &Hypergraph) -> Vec<(Vec<String>, f64)> {
429        let mut dense_subgraphs = Vec::new();
430
431        // For each node type, check density
432        let entity_nodes: Vec<&String> = hypergraph
433            .nodes
434            .iter()
435            .filter(|(_, n)| n.node_type == NodeType::Entity)
436            .map(|(id, _)| id)
437            .collect();
438
439        if entity_nodes.len() >= 3 {
440            // Check pairwise edge sharing
441            let mut shared_counts: HashMap<(&String, &String), usize> = HashMap::new();
442
443            for node in &entity_nodes {
444                if let Some(edges) = hypergraph.node_edges.get(*node) {
445                    let edge_set: HashSet<&String> = edges.iter().collect();
446
447                    for other in &entity_nodes {
448                        if node >= other {
449                            continue;
450                        }
451                        if let Some(other_edges) = hypergraph.node_edges.get(*other) {
452                            let other_set: HashSet<&String> = other_edges.iter().collect();
453                            let shared = edge_set.intersection(&other_set).count();
454                            if shared > 0 {
455                                shared_counts.insert((node, other), shared);
456                            }
457                        }
458                    }
459                }
460            }
461
462            // Group highly connected nodes
463            let avg_shared = if !shared_counts.is_empty() {
464                shared_counts.values().sum::<usize>() as f64 / shared_counts.len() as f64
465            } else {
466                0.0
467            };
468
469            if avg_shared > 1.0 {
470                let high_shared: Vec<_> = shared_counts
471                    .iter()
472                    .filter(|&(_, &count)| count as f64 > avg_shared * 1.5)
473                    .collect();
474
475                if !high_shared.is_empty() {
476                    let mut nodes_in_dense: HashSet<String> = HashSet::new();
477                    for ((a, b), _) in high_shared {
478                        nodes_in_dense.insert((*a).clone());
479                        nodes_in_dense.insert((*b).clone());
480                    }
481
482                    let nodes: Vec<String> = nodes_in_dense.into_iter().collect();
483                    let max_edges = nodes.len() * (nodes.len() - 1) / 2;
484                    let actual_edges = shared_counts
485                        .iter()
486                        .filter(|((a, b), _)| nodes.contains(*a) && nodes.contains(*b))
487                        .count();
488                    let density = if max_edges > 0 {
489                        actual_edges as f64 / max_edges as f64
490                    } else {
491                        0.0
492                    };
493
494                    dense_subgraphs.push((nodes, density));
495                }
496            }
497        }
498
499        dense_subgraphs
500    }
501
502    /// Calculate hypergraph statistics.
503    fn calculate_stats(hypergraph: &Hypergraph) -> HypergraphStats {
504        let node_count = hypergraph.nodes.len();
505        let edge_count = hypergraph.edges.len();
506
507        let avg_edge_size = if edge_count > 0 {
508            hypergraph
509                .edges
510                .iter()
511                .map(|e| e.nodes.len() as f64)
512                .sum::<f64>()
513                / edge_count as f64
514        } else {
515            0.0
516        };
517
518        let avg_node_degree = if node_count > 0 {
519            hypergraph
520                .node_edges
521                .values()
522                .map(|e| e.len() as f64)
523                .sum::<f64>()
524                / node_count as f64
525        } else {
526            0.0
527        };
528
529        let components = Self::find_connected_components(hypergraph);
530        let component_count = components.len();
531
532        // Density for hypergraph: actual edges / possible edges
533        let max_possible_edges = if node_count > 1 {
534            (2_usize.pow(node_count as u32) - node_count - 1) as f64
535        } else {
536            1.0
537        };
538        let density = (edge_count as f64 / max_possible_edges).min(1.0);
539
540        HypergraphStats {
541            node_count,
542            edge_count,
543            avg_edge_size,
544            avg_node_degree,
545            component_count,
546            density,
547        }
548    }
549
550    /// Calculate standard deviation.
551    fn std_dev(values: &[f64]) -> f64 {
552        if values.is_empty() {
553            return 0.0;
554        }
555        let mean = values.iter().sum::<f64>() / values.len() as f64;
556        let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
557        variance.sqrt()
558    }
559
560    /// Get nodes by type.
561    pub fn get_nodes_by_type(
562        result: &HypergraphResult,
563        node_type: NodeType,
564    ) -> Vec<&HypergraphNode> {
565        result
566            .hypergraph
567            .nodes
568            .values()
569            .filter(|n| n.node_type == node_type)
570            .collect()
571    }
572
573    /// Get edges containing a node.
574    pub fn get_node_edges<'a>(result: &'a HypergraphResult, node_id: &str) -> Vec<&'a Hyperedge> {
575        result
576            .hypergraph
577            .node_edges
578            .get(node_id)
579            .map(|edge_ids| {
580                result
581                    .hypergraph
582                    .edges
583                    .iter()
584                    .filter(|e| edge_ids.contains(&e.id))
585                    .collect()
586            })
587            .unwrap_or_default()
588    }
589}
590
591impl GpuKernel for HypergraphConstruction {
592    fn metadata(&self) -> &KernelMetadata {
593        &self.metadata
594    }
595}
596
597// ============================================================================
598// Configuration Types
599// ============================================================================
600
601/// Hypergraph construction configuration.
602#[derive(Debug, Clone)]
603pub struct HypergraphConfig {
604    /// Include entity nodes.
605    pub include_entity_nodes: bool,
606    /// Include account nodes.
607    pub include_account_nodes: bool,
608    /// Include transaction nodes.
609    pub include_transaction_nodes: bool,
610    /// Include category nodes.
611    pub include_category_nodes: bool,
612    /// Include time period nodes.
613    pub include_time_nodes: bool,
614    /// Minimum edge size.
615    pub min_edge_size: usize,
616    /// Detect patterns.
617    pub detect_patterns: bool,
618}
619
620impl Default for HypergraphConfig {
621    fn default() -> Self {
622        Self {
623            include_entity_nodes: true,
624            include_account_nodes: true,
625            include_transaction_nodes: false,
626            include_category_nodes: true,
627            include_time_nodes: false,
628            min_edge_size: 2,
629            detect_patterns: true,
630        }
631    }
632}
633
634// ============================================================================
635// Tests
636// ============================================================================
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn create_test_record(
643        id: &str,
644        entity_id: &str,
645        record_type: AuditRecordType,
646        amount: f64,
647        timestamp: u64,
648        category: &str,
649    ) -> AuditRecord {
650        AuditRecord {
651            id: id.to_string(),
652            record_type,
653            entity_id: entity_id.to_string(),
654            timestamp,
655            amount: Some(amount),
656            currency: Some("USD".to_string()),
657            account: Some(format!("ACC-{}", entity_id)),
658            counter_party: Some("CP001".to_string()),
659            category: category.to_string(),
660            attributes: HashMap::new(),
661        }
662    }
663
664    fn create_test_records() -> Vec<AuditRecord> {
665        vec![
666            create_test_record(
667                "R001",
668                "E001",
669                AuditRecordType::Payment,
670                1000.0,
671                1000000,
672                "Operating",
673            ),
674            create_test_record(
675                "R002",
676                "E001",
677                AuditRecordType::Invoice,
678                1500.0,
679                1000100,
680                "Operating",
681            ),
682            create_test_record(
683                "R003",
684                "E002",
685                AuditRecordType::Payment,
686                500.0,
687                1000200,
688                "Operating",
689            ),
690            create_test_record(
691                "R004",
692                "E002",
693                AuditRecordType::Revenue,
694                10000.0,
695                1000300,
696                "Sales",
697            ),
698            create_test_record(
699                "R005",
700                "E003",
701                AuditRecordType::Expense,
702                3000.0,
703                1000400,
704                "Operating",
705            ),
706        ]
707    }
708
709    #[test]
710    fn test_construct_hypergraph() {
711        let records = create_test_records();
712        let config = HypergraphConfig::default();
713
714        let result = HypergraphConstruction::construct(&records, &config);
715
716        assert!(!result.hypergraph.nodes.is_empty());
717        assert!(!result.hypergraph.edges.is_empty());
718    }
719
720    #[test]
721    fn test_node_types() {
722        let records = create_test_records();
723        let config = HypergraphConfig::default();
724
725        let result = HypergraphConstruction::construct(&records, &config);
726
727        let entity_nodes = HypergraphConstruction::get_nodes_by_type(&result, NodeType::Entity);
728        assert_eq!(entity_nodes.len(), 3); // E001, E002, E003
729
730        let category_nodes = HypergraphConstruction::get_nodes_by_type(&result, NodeType::Category);
731        assert_eq!(category_nodes.len(), 2); // Operating, Sales
732    }
733
734    #[test]
735    fn test_hyperedges() {
736        let records = create_test_records();
737        let config = HypergraphConfig::default();
738
739        let result = HypergraphConstruction::construct(&records, &config);
740
741        // Each record should create one hyperedge
742        assert_eq!(result.hypergraph.edges.len(), 5);
743
744        // Check edge connects multiple nodes
745        for edge in &result.hypergraph.edges {
746            assert!(edge.nodes.len() >= config.min_edge_size);
747        }
748    }
749
750    #[test]
751    fn test_node_centrality() {
752        let records = create_test_records();
753        let config = HypergraphConfig::default();
754
755        let result = HypergraphConstruction::construct(&records, &config);
756
757        // Operating category should have high centrality (appears in 4 records)
758        let operating_cent = result.node_centrality.get("category:Operating").unwrap();
759        let sales_cent = result.node_centrality.get("category:Sales").unwrap();
760        assert!(operating_cent > sales_cent);
761    }
762
763    #[test]
764    fn test_statistics() {
765        let records = create_test_records();
766        let config = HypergraphConfig::default();
767
768        let result = HypergraphConstruction::construct(&records, &config);
769
770        assert!(result.stats.node_count > 0);
771        assert!(result.stats.edge_count > 0);
772        assert!(result.stats.avg_edge_size > 0.0);
773    }
774
775    #[test]
776    fn test_empty_records() {
777        let records: Vec<AuditRecord> = vec![];
778        let config = HypergraphConfig::default();
779
780        let result = HypergraphConstruction::construct(&records, &config);
781
782        assert!(result.hypergraph.nodes.is_empty());
783        assert!(result.hypergraph.edges.is_empty());
784    }
785
786    #[test]
787    fn test_selective_nodes() {
788        let records = create_test_records();
789        let config = HypergraphConfig {
790            include_entity_nodes: true,
791            include_account_nodes: false,
792            include_transaction_nodes: false,
793            include_category_nodes: false,
794            include_time_nodes: false,
795            min_edge_size: 1,
796            detect_patterns: false,
797        };
798
799        let result = HypergraphConstruction::construct(&records, &config);
800
801        // Should only have entity nodes
802        for node in result.hypergraph.nodes.values() {
803            assert_eq!(node.node_type, NodeType::Entity);
804        }
805    }
806
807    #[test]
808    fn test_get_node_edges() {
809        let records = create_test_records();
810        let config = HypergraphConfig::default();
811
812        let result = HypergraphConstruction::construct(&records, &config);
813
814        let e001_edges = HypergraphConstruction::get_node_edges(&result, "entity:E001");
815        assert_eq!(e001_edges.len(), 2); // E001 has 2 records
816    }
817
818    #[test]
819    fn test_connected_components() {
820        let records = create_test_records();
821        let config = HypergraphConfig::default();
822
823        let result = HypergraphConstruction::construct(&records, &config);
824
825        // All records share "Operating" category, so should be connected
826        assert_eq!(result.stats.component_count, 1);
827    }
828
829    #[test]
830    fn test_edge_weights() {
831        let records = create_test_records();
832        let config = HypergraphConfig::default();
833
834        let result = HypergraphConstruction::construct(&records, &config);
835
836        // Weights should be normalized 0-1
837        for weight in result.edge_weights.values() {
838            assert!(*weight >= 0.0 && *weight <= 1.0);
839        }
840
841        // At least one edge should have weight 1.0 (the max)
842        assert!(
843            result
844                .edge_weights
845                .values()
846                .any(|w| (*w - 1.0).abs() < 0.01)
847        );
848    }
849
850    #[test]
851    fn test_min_edge_size() {
852        let records = create_test_records();
853        let config = HypergraphConfig {
854            min_edge_size: 5, // Require 5 nodes per edge
855            ..Default::default()
856        };
857
858        let result = HypergraphConstruction::construct(&records, &config);
859
860        // With min_edge_size=5, most edges won't qualify
861        for edge in &result.hypergraph.edges {
862            assert!(edge.nodes.len() >= 5);
863        }
864    }
865}