1use std::collections::HashMap;
9
10use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum HypergraphLayer {
18 GovernanceControls,
20 ProcessEvents,
22 AccountingNetwork,
24}
25
26impl HypergraphLayer {
27 pub fn index(&self) -> u8 {
29 match self {
30 HypergraphLayer::GovernanceControls => 1,
31 HypergraphLayer::ProcessEvents => 2,
32 HypergraphLayer::AccountingNetwork => 3,
33 }
34 }
35
36 pub fn name(&self) -> &'static str {
38 match self {
39 HypergraphLayer::GovernanceControls => "Governance & Controls",
40 HypergraphLayer::ProcessEvents => "Process Events",
41 HypergraphLayer::AccountingNetwork => "Accounting Network",
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum AggregationStrategy {
50 Truncate,
52 #[default]
54 PoolByCounterparty,
55 PoolByTimePeriod,
57 ImportanceSample,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct HyperedgeParticipant {
64 pub node_id: String,
66 pub role: String,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub weight: Option<f64>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Hyperedge {
76 pub id: String,
78 pub hyperedge_type: String,
80 pub subtype: String,
82 pub participants: Vec<HyperedgeParticipant>,
84 pub layer: HypergraphLayer,
86 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
88 pub properties: HashMap<String, Value>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub timestamp: Option<NaiveDate>,
92 #[serde(default)]
94 pub is_anomaly: bool,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub anomaly_type: Option<String>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub features: Vec<f64>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct HypergraphNode {
106 pub id: String,
108 pub entity_type: String,
110 pub entity_type_code: u32,
112 pub layer: HypergraphLayer,
114 pub external_id: String,
116 pub label: String,
118 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
120 pub properties: HashMap<String, Value>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub features: Vec<f64>,
124 #[serde(default)]
126 pub is_anomaly: bool,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub anomaly_type: Option<String>,
130 #[serde(default)]
132 pub is_aggregate: bool,
133 #[serde(default)]
135 pub aggregate_count: usize,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct CrossLayerEdge {
141 pub source_id: String,
143 pub source_layer: HypergraphLayer,
145 pub target_id: String,
147 pub target_layer: HypergraphLayer,
149 pub edge_type: String,
151 pub edge_type_code: u32,
153 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
155 pub properties: HashMap<String, Value>,
156}
157
158const DEFAULT_L1_BUDGET_DIVISOR: usize = 5;
160const DEFAULT_L3_BUDGET_DIVISOR: usize = 10;
162
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169pub struct NodeBudgetSuggestion {
170 pub l1_suggested: usize,
172 pub l2_suggested: usize,
174 pub l3_suggested: usize,
176 pub total: usize,
178 pub surplus_redistributed: usize,
180}
181
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184pub struct NodeBudget {
185 pub layer1_max: usize,
187 pub layer2_max: usize,
189 pub layer3_max: usize,
191 pub layer1_count: usize,
193 pub layer2_count: usize,
195 pub layer3_count: usize,
197}
198
199impl NodeBudget {
200 pub fn new(max_nodes: usize) -> Self {
203 let l1 = max_nodes / DEFAULT_L1_BUDGET_DIVISOR;
204 let l3 = max_nodes / DEFAULT_L3_BUDGET_DIVISOR;
205 let l2 = max_nodes - l1 - l3; Self {
207 layer1_max: l1,
208 layer2_max: l2,
209 layer3_max: l3,
210 layer1_count: 0,
211 layer2_count: 0,
212 layer3_count: 0,
213 }
214 }
215
216 pub fn can_add(&self, layer: HypergraphLayer) -> bool {
218 match layer {
219 HypergraphLayer::GovernanceControls => self.layer1_count < self.layer1_max,
220 HypergraphLayer::ProcessEvents => self.layer2_count < self.layer2_max,
221 HypergraphLayer::AccountingNetwork => self.layer3_count < self.layer3_max,
222 }
223 }
224
225 pub fn record_add(&mut self, layer: HypergraphLayer) {
227 match layer {
228 HypergraphLayer::GovernanceControls => self.layer1_count += 1,
229 HypergraphLayer::ProcessEvents => self.layer2_count += 1,
230 HypergraphLayer::AccountingNetwork => self.layer3_count += 1,
231 }
232 }
233
234 pub fn total_count(&self) -> usize {
236 self.layer1_count + self.layer2_count + self.layer3_count
237 }
238
239 pub fn total_max(&self) -> usize {
241 self.layer1_max + self.layer2_max + self.layer3_max
242 }
243
244 pub fn suggest(
250 &self,
251 l1_demand: usize,
252 l2_demand: usize,
253 l3_demand: usize,
254 ) -> NodeBudgetSuggestion {
255 let total = self.total_max();
256
257 let l1_clamped = l1_demand.min(self.layer1_max);
259 let l2_clamped = l2_demand.min(self.layer2_max);
260 let l3_clamped = l3_demand.min(self.layer3_max);
261
262 let surplus = (self.layer1_max - l1_clamped)
264 + (self.layer2_max - l2_clamped)
265 + (self.layer3_max - l3_clamped);
266
267 let l1_unsat = l1_demand.saturating_sub(self.layer1_max);
269 let l2_unsat = l2_demand.saturating_sub(self.layer2_max);
270 let l3_unsat = l3_demand.saturating_sub(self.layer3_max);
271 let total_unsat = l1_unsat + l2_unsat + l3_unsat;
272
273 let (l1_bonus, l2_bonus, l3_bonus) = if total_unsat > 0 && surplus > 0 {
275 let l1_b = (surplus as f64 * l1_unsat as f64 / total_unsat as f64).floor() as usize;
276 let l2_b = (surplus as f64 * l2_unsat as f64 / total_unsat as f64).floor() as usize;
277 let l3_b = surplus.saturating_sub(l1_b).saturating_sub(l2_b);
279 (l1_b, l2_b, l3_b)
280 } else if surplus > 0 {
281 (0, surplus, 0)
283 } else {
284 (0, 0, 0)
285 };
286
287 let l1_suggested = l1_clamped + l1_bonus;
288 let l2_suggested = l2_clamped + l2_bonus;
289 let l3_suggested = l3_clamped + l3_bonus;
290 let redistributed = l1_bonus + l2_bonus + l3_bonus;
291
292 NodeBudgetSuggestion {
293 l1_suggested,
294 l2_suggested,
295 l3_suggested,
296 total,
297 surplus_redistributed: redistributed,
298 }
299 }
300
301 pub fn rebalance(&mut self, l1_demand: usize, l2_demand: usize, l3_demand: usize) {
308 let suggestion = self.suggest(l1_demand, l2_demand, l3_demand);
309 self.layer1_max = suggestion.l1_suggested;
310 self.layer2_max = suggestion.l2_suggested;
311 self.layer3_max = suggestion.l3_suggested;
312 }
313}
314
315#[derive(Debug, Clone, Default, Serialize, Deserialize)]
317pub struct NodeBudgetReport {
318 pub total_budget: usize,
320 pub total_used: usize,
322 pub layer1_budget: usize,
324 pub layer1_used: usize,
325 pub layer2_budget: usize,
327 pub layer2_used: usize,
328 pub layer3_budget: usize,
330 pub layer3_used: usize,
331 pub aggregate_nodes_created: usize,
333 pub aggregation_triggered: bool,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct HypergraphMetadata {
340 pub name: String,
342 pub num_nodes: usize,
344 pub num_edges: usize,
346 pub num_hyperedges: usize,
348 pub layer_node_counts: HashMap<String, usize>,
350 pub node_type_counts: HashMap<String, usize>,
352 pub edge_type_counts: HashMap<String, usize>,
354 pub hyperedge_type_counts: HashMap<String, usize>,
356 pub anomalous_nodes: usize,
358 pub anomalous_hyperedges: usize,
360 pub source: String,
362 pub generated_at: String,
364 pub budget_report: NodeBudgetReport,
366 pub files: Vec<String>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct Hypergraph {
373 pub nodes: Vec<HypergraphNode>,
375 pub edges: Vec<CrossLayerEdge>,
377 pub hyperedges: Vec<Hyperedge>,
379 pub metadata: HypergraphMetadata,
381 pub budget_report: NodeBudgetReport,
383}
384
385#[cfg(test)]
386#[allow(clippy::unwrap_used)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn test_layer_index() {
392 assert_eq!(HypergraphLayer::GovernanceControls.index(), 1);
393 assert_eq!(HypergraphLayer::ProcessEvents.index(), 2);
394 assert_eq!(HypergraphLayer::AccountingNetwork.index(), 3);
395 }
396
397 #[test]
398 fn test_node_budget_new() {
399 let budget = NodeBudget::new(50_000);
400 assert_eq!(budget.layer1_max, 10_000); assert_eq!(budget.layer2_max, 35_000); assert_eq!(budget.layer3_max, 5_000); assert_eq!(budget.total_max(), 50_000);
404 }
405
406 #[test]
407 fn test_node_budget_can_add() {
408 let mut budget = NodeBudget::new(100);
409 assert!(budget.can_add(HypergraphLayer::GovernanceControls));
410
411 for _ in 0..20 {
413 budget.record_add(HypergraphLayer::GovernanceControls);
414 }
415 assert!(!budget.can_add(HypergraphLayer::GovernanceControls));
416 assert!(budget.can_add(HypergraphLayer::ProcessEvents));
417 }
418
419 #[test]
420 fn test_node_budget_total() {
421 let mut budget = NodeBudget::new(1000);
422 budget.record_add(HypergraphLayer::GovernanceControls);
423 budget.record_add(HypergraphLayer::ProcessEvents);
424 budget.record_add(HypergraphLayer::AccountingNetwork);
425 assert_eq!(budget.total_count(), 3);
426 }
427
428 #[test]
429 fn test_hypergraph_node_serialization() {
430 let node = HypergraphNode {
431 id: "node_1".to_string(),
432 entity_type: "account".to_string(),
433 entity_type_code: 100,
434 layer: HypergraphLayer::AccountingNetwork,
435 external_id: "1000".to_string(),
436 label: "Cash".to_string(),
437 properties: HashMap::new(),
438 features: vec![1.0, 2.0],
439 is_anomaly: false,
440 anomaly_type: None,
441 is_aggregate: false,
442 aggregate_count: 0,
443 };
444
445 let json = serde_json::to_string(&node).unwrap();
446 let deserialized: HypergraphNode = serde_json::from_str(&json).unwrap();
447 assert_eq!(deserialized.id, "node_1");
448 assert_eq!(deserialized.entity_type_code, 100);
449 assert_eq!(deserialized.layer, HypergraphLayer::AccountingNetwork);
450 }
451
452 #[test]
453 fn test_hyperedge_serialization() {
454 let he = Hyperedge {
455 id: "he_1".to_string(),
456 hyperedge_type: "JournalEntry".to_string(),
457 subtype: "R2R".to_string(),
458 participants: vec![
459 HyperedgeParticipant {
460 node_id: "acct_1000".to_string(),
461 role: "debit".to_string(),
462 weight: Some(500.0),
463 },
464 HyperedgeParticipant {
465 node_id: "acct_2000".to_string(),
466 role: "credit".to_string(),
467 weight: Some(500.0),
468 },
469 ],
470 layer: HypergraphLayer::AccountingNetwork,
471 properties: HashMap::new(),
472 timestamp: Some(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
473 is_anomaly: true,
474 anomaly_type: Some("split_transaction".to_string()),
475 features: vec![6.2, 1.0],
476 };
477
478 let json = serde_json::to_string(&he).unwrap();
479 let deserialized: Hyperedge = serde_json::from_str(&json).unwrap();
480 assert_eq!(deserialized.participants.len(), 2);
481 assert!(deserialized.is_anomaly);
482 }
483
484 #[test]
485 fn test_cross_layer_edge_serialization() {
486 let edge = CrossLayerEdge {
487 source_id: "ctrl_C001".to_string(),
488 source_layer: HypergraphLayer::GovernanceControls,
489 target_id: "acct_1000".to_string(),
490 target_layer: HypergraphLayer::AccountingNetwork,
491 edge_type: "ImplementsControl".to_string(),
492 edge_type_code: 40,
493 properties: HashMap::new(),
494 };
495
496 let json = serde_json::to_string(&edge).unwrap();
497 let deserialized: CrossLayerEdge = serde_json::from_str(&json).unwrap();
498 assert_eq!(deserialized.edge_type, "ImplementsControl");
499 assert_eq!(
500 deserialized.source_layer,
501 HypergraphLayer::GovernanceControls
502 );
503 assert_eq!(
504 deserialized.target_layer,
505 HypergraphLayer::AccountingNetwork
506 );
507 }
508
509 #[test]
510 fn test_aggregation_strategy_default() {
511 assert_eq!(
512 AggregationStrategy::default(),
513 AggregationStrategy::PoolByCounterparty
514 );
515 }
516}