use std::collections::HashMap;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HypergraphLayer {
GovernanceControls,
ProcessEvents,
AccountingNetwork,
}
impl HypergraphLayer {
pub fn index(&self) -> u8 {
match self {
HypergraphLayer::GovernanceControls => 1,
HypergraphLayer::ProcessEvents => 2,
HypergraphLayer::AccountingNetwork => 3,
}
}
pub fn name(&self) -> &'static str {
match self {
HypergraphLayer::GovernanceControls => "Governance & Controls",
HypergraphLayer::ProcessEvents => "Process Events",
HypergraphLayer::AccountingNetwork => "Accounting Network",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AggregationStrategy {
Truncate,
#[default]
PoolByCounterparty,
PoolByTimePeriod,
ImportanceSample,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HyperedgeParticipant {
pub node_id: String,
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub weight: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hyperedge {
pub id: String,
pub hyperedge_type: String,
pub subtype: String,
pub participants: Vec<HyperedgeParticipant>,
pub layer: HypergraphLayer,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub properties: HashMap<String, Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<NaiveDate>,
#[serde(default)]
pub is_anomaly: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub anomaly_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HypergraphNode {
pub id: String,
pub entity_type: String,
pub entity_type_code: u32,
pub layer: HypergraphLayer,
pub external_id: String,
pub label: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub properties: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<f64>,
#[serde(default)]
pub is_anomaly: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub anomaly_type: Option<String>,
#[serde(default)]
pub is_aggregate: bool,
#[serde(default)]
pub aggregate_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossLayerEdge {
pub source_id: String,
pub source_layer: HypergraphLayer,
pub target_id: String,
pub target_layer: HypergraphLayer,
pub edge_type: String,
pub edge_type_code: u32,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub properties: HashMap<String, Value>,
}
const DEFAULT_L1_BUDGET_DIVISOR: usize = 5;
const DEFAULT_L3_BUDGET_DIVISOR: usize = 10;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NodeBudgetSuggestion {
pub l1_suggested: usize,
pub l2_suggested: usize,
pub l3_suggested: usize,
pub total: usize,
pub surplus_redistributed: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NodeBudget {
pub layer1_max: usize,
pub layer2_max: usize,
pub layer3_max: usize,
pub layer1_count: usize,
pub layer2_count: usize,
pub layer3_count: usize,
}
impl NodeBudget {
pub fn new(max_nodes: usize) -> Self {
let l1 = max_nodes / DEFAULT_L1_BUDGET_DIVISOR;
let l3 = max_nodes / DEFAULT_L3_BUDGET_DIVISOR;
let l2 = max_nodes - l1 - l3; Self {
layer1_max: l1,
layer2_max: l2,
layer3_max: l3,
layer1_count: 0,
layer2_count: 0,
layer3_count: 0,
}
}
pub fn can_add(&self, layer: HypergraphLayer) -> bool {
match layer {
HypergraphLayer::GovernanceControls => self.layer1_count < self.layer1_max,
HypergraphLayer::ProcessEvents => self.layer2_count < self.layer2_max,
HypergraphLayer::AccountingNetwork => self.layer3_count < self.layer3_max,
}
}
pub fn record_add(&mut self, layer: HypergraphLayer) {
match layer {
HypergraphLayer::GovernanceControls => self.layer1_count += 1,
HypergraphLayer::ProcessEvents => self.layer2_count += 1,
HypergraphLayer::AccountingNetwork => self.layer3_count += 1,
}
}
pub fn total_count(&self) -> usize {
self.layer1_count + self.layer2_count + self.layer3_count
}
pub fn total_max(&self) -> usize {
self.layer1_max + self.layer2_max + self.layer3_max
}
pub fn suggest(
&self,
l1_demand: usize,
l2_demand: usize,
l3_demand: usize,
) -> NodeBudgetSuggestion {
let total = self.total_max();
let l1_clamped = l1_demand.min(self.layer1_max);
let l2_clamped = l2_demand.min(self.layer2_max);
let l3_clamped = l3_demand.min(self.layer3_max);
let surplus = (self.layer1_max - l1_clamped)
+ (self.layer2_max - l2_clamped)
+ (self.layer3_max - l3_clamped);
let l1_unsat = l1_demand.saturating_sub(self.layer1_max);
let l2_unsat = l2_demand.saturating_sub(self.layer2_max);
let l3_unsat = l3_demand.saturating_sub(self.layer3_max);
let total_unsat = l1_unsat + l2_unsat + l3_unsat;
let (l1_bonus, l2_bonus, l3_bonus) = if total_unsat > 0 && surplus > 0 {
let l1_b = (surplus as f64 * l1_unsat as f64 / total_unsat as f64).floor() as usize;
let l2_b = (surplus as f64 * l2_unsat as f64 / total_unsat as f64).floor() as usize;
let l3_b = surplus.saturating_sub(l1_b).saturating_sub(l2_b);
(l1_b, l2_b, l3_b)
} else if surplus > 0 {
(0, surplus, 0)
} else {
(0, 0, 0)
};
let l1_suggested = l1_clamped + l1_bonus;
let l2_suggested = l2_clamped + l2_bonus;
let l3_suggested = l3_clamped + l3_bonus;
let redistributed = l1_bonus + l2_bonus + l3_bonus;
NodeBudgetSuggestion {
l1_suggested,
l2_suggested,
l3_suggested,
total,
surplus_redistributed: redistributed,
}
}
pub fn rebalance(&mut self, l1_demand: usize, l2_demand: usize, l3_demand: usize) {
let suggestion = self.suggest(l1_demand, l2_demand, l3_demand);
self.layer1_max = suggestion.l1_suggested;
self.layer2_max = suggestion.l2_suggested;
self.layer3_max = suggestion.l3_suggested;
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NodeBudgetReport {
pub total_budget: usize,
pub total_used: usize,
pub layer1_budget: usize,
pub layer1_used: usize,
pub layer2_budget: usize,
pub layer2_used: usize,
pub layer3_budget: usize,
pub layer3_used: usize,
pub aggregate_nodes_created: usize,
pub aggregation_triggered: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HypergraphMetadata {
pub name: String,
pub num_nodes: usize,
pub num_edges: usize,
pub num_hyperedges: usize,
pub layer_node_counts: HashMap<String, usize>,
pub node_type_counts: HashMap<String, usize>,
pub edge_type_counts: HashMap<String, usize>,
pub hyperedge_type_counts: HashMap<String, usize>,
pub anomalous_nodes: usize,
pub anomalous_hyperedges: usize,
pub source: String,
pub generated_at: String,
pub budget_report: NodeBudgetReport,
pub files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hypergraph {
pub nodes: Vec<HypergraphNode>,
pub edges: Vec<CrossLayerEdge>,
pub hyperedges: Vec<Hyperedge>,
pub metadata: HypergraphMetadata,
pub budget_report: NodeBudgetReport,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_layer_index() {
assert_eq!(HypergraphLayer::GovernanceControls.index(), 1);
assert_eq!(HypergraphLayer::ProcessEvents.index(), 2);
assert_eq!(HypergraphLayer::AccountingNetwork.index(), 3);
}
#[test]
fn test_node_budget_new() {
let budget = NodeBudget::new(50_000);
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);
}
#[test]
fn test_node_budget_can_add() {
let mut budget = NodeBudget::new(100);
assert!(budget.can_add(HypergraphLayer::GovernanceControls));
for _ in 0..20 {
budget.record_add(HypergraphLayer::GovernanceControls);
}
assert!(!budget.can_add(HypergraphLayer::GovernanceControls));
assert!(budget.can_add(HypergraphLayer::ProcessEvents));
}
#[test]
fn test_node_budget_total() {
let mut budget = NodeBudget::new(1000);
budget.record_add(HypergraphLayer::GovernanceControls);
budget.record_add(HypergraphLayer::ProcessEvents);
budget.record_add(HypergraphLayer::AccountingNetwork);
assert_eq!(budget.total_count(), 3);
}
#[test]
fn test_hypergraph_node_serialization() {
let node = HypergraphNode {
id: "node_1".to_string(),
entity_type: "account".to_string(),
entity_type_code: 100,
layer: HypergraphLayer::AccountingNetwork,
external_id: "1000".to_string(),
label: "Cash".to_string(),
properties: HashMap::new(),
features: vec![1.0, 2.0],
is_anomaly: false,
anomaly_type: None,
is_aggregate: false,
aggregate_count: 0,
};
let json = serde_json::to_string(&node).unwrap();
let deserialized: HypergraphNode = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "node_1");
assert_eq!(deserialized.entity_type_code, 100);
assert_eq!(deserialized.layer, HypergraphLayer::AccountingNetwork);
}
#[test]
fn test_hyperedge_serialization() {
let he = Hyperedge {
id: "he_1".to_string(),
hyperedge_type: "JournalEntry".to_string(),
subtype: "R2R".to_string(),
participants: vec![
HyperedgeParticipant {
node_id: "acct_1000".to_string(),
role: "debit".to_string(),
weight: Some(500.0),
},
HyperedgeParticipant {
node_id: "acct_2000".to_string(),
role: "credit".to_string(),
weight: Some(500.0),
},
],
layer: HypergraphLayer::AccountingNetwork,
properties: HashMap::new(),
timestamp: Some(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()),
is_anomaly: true,
anomaly_type: Some("split_transaction".to_string()),
features: vec![6.2, 1.0],
};
let json = serde_json::to_string(&he).unwrap();
let deserialized: Hyperedge = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.participants.len(), 2);
assert!(deserialized.is_anomaly);
}
#[test]
fn test_cross_layer_edge_serialization() {
let edge = CrossLayerEdge {
source_id: "ctrl_C001".to_string(),
source_layer: HypergraphLayer::GovernanceControls,
target_id: "acct_1000".to_string(),
target_layer: HypergraphLayer::AccountingNetwork,
edge_type: "ImplementsControl".to_string(),
edge_type_code: 40,
properties: HashMap::new(),
};
let json = serde_json::to_string(&edge).unwrap();
let deserialized: CrossLayerEdge = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.edge_type, "ImplementsControl");
assert_eq!(
deserialized.source_layer,
HypergraphLayer::GovernanceControls
);
assert_eq!(
deserialized.target_layer,
HypergraphLayer::AccountingNetwork
);
}
#[test]
fn test_aggregation_strategy_default() {
assert_eq!(
AggregationStrategy::default(),
AggregationStrategy::PoolByCounterparty
);
}
}