1use std::collections::HashMap;
31
32use crate::models::{EdgeType, Graph, GraphEdge, GraphNode, GraphType, NodeId, NodeType};
33
34#[derive(Debug, Clone)]
36pub struct ComplianceGraphConfig {
37 pub include_standard_nodes: bool,
39 pub include_jurisdiction_nodes: bool,
41 pub include_cross_references: bool,
43 pub include_supersession_edges: bool,
45 pub include_account_links: bool,
47 pub include_control_links: bool,
49 pub include_company_links: bool,
51}
52
53impl Default for ComplianceGraphConfig {
54 fn default() -> Self {
55 Self {
56 include_standard_nodes: true,
57 include_jurisdiction_nodes: true,
58 include_cross_references: true,
59 include_supersession_edges: false,
60 include_account_links: true,
61 include_control_links: true,
62 include_company_links: true,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct StandardNodeInput {
70 pub standard_id: String,
71 pub title: String,
72 pub category: String,
73 pub domain: String,
74 pub is_active: bool,
75 pub features: Vec<f64>,
77 pub applicable_account_types: Vec<String>,
79 pub applicable_processes: Vec<String>,
81}
82
83#[derive(Debug, Clone)]
85pub struct JurisdictionNodeInput {
86 pub country_code: String,
87 pub country_name: String,
88 pub framework: String,
89 pub standard_count: usize,
90 pub tax_rate: f64,
91}
92
93#[derive(Debug, Clone)]
95pub struct CrossReferenceEdgeInput {
96 pub from_standard: String,
97 pub to_standard: String,
98 pub relationship: String,
99 pub convergence_level: f64,
100}
101
102#[derive(Debug, Clone)]
104pub struct SupersessionEdgeInput {
105 pub old_standard: String,
106 pub new_standard: String,
107}
108
109#[derive(Debug, Clone)]
111pub struct JurisdictionMappingInput {
112 pub country_code: String,
113 pub standard_id: String,
114}
115
116#[derive(Debug, Clone)]
118pub struct ProcedureNodeInput {
119 pub procedure_id: String,
120 pub standard_id: String,
121 pub procedure_type: String,
122 pub sample_size: u32,
123 pub confidence_level: f64,
124}
125
126#[derive(Debug, Clone)]
128pub struct FindingNodeInput {
129 pub finding_id: String,
130 pub standard_id: String,
131 pub severity: String,
132 pub deficiency_level: String,
133 pub severity_score: f64,
134 pub control_id: Option<String>,
136 pub affected_accounts: Vec<String>,
138}
139
140#[derive(Debug, Clone)]
142pub struct AccountLinkInput {
143 pub standard_id: String,
144 pub account_code: String,
145 pub account_name: String,
146}
147
148#[derive(Debug, Clone)]
150pub struct ControlLinkInput {
151 pub standard_id: String,
152 pub control_id: String,
153 pub control_name: String,
154}
155
156#[derive(Debug, Clone)]
158pub struct FilingNodeInput {
159 pub filing_id: String,
160 pub filing_type: String,
161 pub company_code: String,
162 pub jurisdiction: String,
163 pub status: String,
164}
165
166pub struct ComplianceGraphBuilder {
168 config: ComplianceGraphConfig,
169 graph: Graph,
170 standard_nodes: HashMap<String, NodeId>,
172 jurisdiction_nodes: HashMap<String, NodeId>,
174 procedure_nodes: HashMap<String, NodeId>,
176 finding_nodes: HashMap<String, NodeId>,
178 account_nodes: HashMap<String, NodeId>,
180 control_nodes: HashMap<String, NodeId>,
182 filing_nodes: HashMap<String, NodeId>,
184 company_nodes: HashMap<String, NodeId>,
186}
187
188impl ComplianceGraphBuilder {
189 pub fn new(config: ComplianceGraphConfig) -> Self {
191 Self {
192 config,
193 graph: Graph::new(
194 "compliance_regulation_network",
195 GraphType::Custom("ComplianceRegulation".to_string()),
196 ),
197 standard_nodes: HashMap::new(),
198 jurisdiction_nodes: HashMap::new(),
199 procedure_nodes: HashMap::new(),
200 finding_nodes: HashMap::new(),
201 account_nodes: HashMap::new(),
202 control_nodes: HashMap::new(),
203 filing_nodes: HashMap::new(),
204 company_nodes: HashMap::new(),
205 }
206 }
207
208 pub fn add_standards(&mut self, standards: &[StandardNodeInput]) {
210 if !self.config.include_standard_nodes {
211 return;
212 }
213
214 for std in standards {
215 if self.standard_nodes.contains_key(&std.standard_id) {
216 continue;
217 }
218
219 let mut node = GraphNode::new(
220 0,
221 NodeType::Custom("Standard".to_string()),
222 std.standard_id.clone(),
223 std.title.clone(),
224 )
225 .with_features(std.features.clone())
226 .with_categorical("category", &std.category)
227 .with_categorical("domain", &std.domain)
228 .with_categorical("is_active", if std.is_active { "true" } else { "false" });
229
230 if !std.applicable_processes.is_empty() {
231 node = node
232 .with_categorical("applicable_processes", &std.applicable_processes.join(";"));
233 }
234 if !std.applicable_account_types.is_empty() {
235 node = node.with_categorical(
236 "applicable_account_types",
237 &std.applicable_account_types.join(";"),
238 );
239 }
240
241 let id = self.graph.add_node(node);
242 self.standard_nodes.insert(std.standard_id.clone(), id);
243 }
244 }
245
246 pub fn add_jurisdictions(&mut self, jurisdictions: &[JurisdictionNodeInput]) {
248 if !self.config.include_jurisdiction_nodes {
249 return;
250 }
251
252 for jp in jurisdictions {
253 if self.jurisdiction_nodes.contains_key(&jp.country_code) {
254 continue;
255 }
256
257 let node = GraphNode::new(
258 0,
259 NodeType::Custom("Jurisdiction".to_string()),
260 jp.country_code.clone(),
261 jp.country_name.clone(),
262 )
263 .with_feature(jp.standard_count as f64)
264 .with_feature(jp.tax_rate)
265 .with_categorical("framework", &jp.framework);
266
267 let id = self.graph.add_node(node);
268 self.jurisdiction_nodes.insert(jp.country_code.clone(), id);
269 }
270 }
271
272 pub fn add_cross_references(&mut self, xrefs: &[CrossReferenceEdgeInput]) {
274 if !self.config.include_cross_references {
275 return;
276 }
277
278 for xref in xrefs {
279 if let (Some(&from_id), Some(&to_id)) = (
280 self.standard_nodes.get(&xref.from_standard),
281 self.standard_nodes.get(&xref.to_standard),
282 ) {
283 let edge = GraphEdge::new(
284 0,
285 from_id,
286 to_id,
287 EdgeType::Custom(format!("CrossReference:{}", xref.relationship)),
288 )
289 .with_weight(xref.convergence_level)
290 .with_feature(xref.convergence_level);
291
292 self.graph.add_edge(edge);
293 }
294 }
295 }
296
297 pub fn add_supersessions(&mut self, supersessions: &[SupersessionEdgeInput]) {
299 if !self.config.include_supersession_edges {
300 return;
301 }
302
303 for sup in supersessions {
304 if let (Some(&old_id), Some(&new_id)) = (
305 self.standard_nodes.get(&sup.old_standard),
306 self.standard_nodes.get(&sup.new_standard),
307 ) {
308 let edge = GraphEdge::new(
309 0,
310 old_id,
311 new_id,
312 EdgeType::Custom("Supersedes".to_string()),
313 )
314 .with_weight(1.0);
315
316 self.graph.add_edge(edge);
317 }
318 }
319 }
320
321 pub fn add_jurisdiction_mappings(&mut self, mappings: &[JurisdictionMappingInput]) {
323 for mapping in mappings {
324 if let (Some(&jp_id), Some(&std_id)) = (
325 self.jurisdiction_nodes.get(&mapping.country_code),
326 self.standard_nodes.get(&mapping.standard_id),
327 ) {
328 let edge = GraphEdge::new(
329 0,
330 jp_id,
331 std_id,
332 EdgeType::Custom("MapsToStandard".to_string()),
333 )
334 .with_weight(1.0);
335
336 self.graph.add_edge(edge);
337 }
338 }
339 }
340
341 pub fn add_procedures(&mut self, procedures: &[ProcedureNodeInput]) {
343 for proc in procedures {
344 if self.procedure_nodes.contains_key(&proc.procedure_id) {
345 continue;
346 }
347
348 let node = GraphNode::new(
349 0,
350 NodeType::Custom("AuditProcedure".to_string()),
351 proc.procedure_id.clone(),
352 format!("{} [{}]", proc.procedure_type, proc.standard_id),
353 )
354 .with_feature(proc.sample_size as f64)
355 .with_feature(proc.confidence_level)
356 .with_categorical("procedure_type", &proc.procedure_type);
357
358 let proc_node_id = self.graph.add_node(node);
359 self.procedure_nodes
360 .insert(proc.procedure_id.clone(), proc_node_id);
361
362 if let Some(&std_id) = self.standard_nodes.get(&proc.standard_id) {
364 let edge = GraphEdge::new(
365 0,
366 proc_node_id,
367 std_id,
368 EdgeType::Custom("TestsCompliance".to_string()),
369 )
370 .with_weight(1.0);
371
372 self.graph.add_edge(edge);
373 }
374 }
375 }
376
377 pub fn add_findings(&mut self, findings: &[FindingNodeInput]) {
379 for finding in findings {
380 if self.finding_nodes.contains_key(&finding.finding_id) {
381 continue;
382 }
383
384 let node = GraphNode::new(
385 0,
386 NodeType::Custom("Finding".to_string()),
387 finding.finding_id.clone(),
388 format!("{} [{}]", finding.deficiency_level, finding.standard_id),
389 )
390 .with_feature(finding.severity_score)
391 .with_categorical("severity", &finding.severity)
392 .with_categorical("deficiency_level", &finding.deficiency_level);
393
394 let finding_node_id = self.graph.add_node(node);
395 self.finding_nodes
396 .insert(finding.finding_id.clone(), finding_node_id);
397
398 if let Some(&std_id) = self.standard_nodes.get(&finding.standard_id) {
400 let edge = GraphEdge::new(
401 0,
402 finding_node_id,
403 std_id,
404 EdgeType::Custom("FindingOnStandard".to_string()),
405 )
406 .with_weight(finding.severity_score);
407
408 self.graph.add_edge(edge);
409 }
410
411 if let Some(ref ctrl_id) = finding.control_id {
413 if let Some(&ctrl_node) = self.control_nodes.get(ctrl_id) {
414 let edge = GraphEdge::new(
415 0,
416 finding_node_id,
417 ctrl_node,
418 EdgeType::Custom("FindingAffectsControl".to_string()),
419 )
420 .with_weight(finding.severity_score);
421 self.graph.add_edge(edge);
422 }
423 }
424
425 if self.config.include_account_links {
427 for acct_code in &finding.affected_accounts {
428 if let Some(&acct_node) = self.account_nodes.get(acct_code) {
429 let edge = GraphEdge::new(
430 0,
431 finding_node_id,
432 acct_node,
433 EdgeType::Custom("FindingAffectsAccount".to_string()),
434 )
435 .with_weight(finding.severity_score);
436 self.graph.add_edge(edge);
437 }
438 }
439 }
440 }
441 }
442
443 pub fn add_account_links(&mut self, links: &[AccountLinkInput]) {
445 if !self.config.include_account_links {
446 return;
447 }
448
449 for link in links {
450 let acct_id = *self
452 .account_nodes
453 .entry(link.account_code.clone())
454 .or_insert_with(|| {
455 let node = GraphNode::new(
456 0,
457 NodeType::Account,
458 link.account_code.clone(),
459 link.account_name.clone(),
460 );
461 self.graph.add_node(node)
462 });
463
464 if let Some(&std_id) = self.standard_nodes.get(&link.standard_id) {
466 let edge = GraphEdge::new(
467 0,
468 std_id,
469 acct_id,
470 EdgeType::Custom("GovernedByStandard".to_string()),
471 )
472 .with_weight(1.0);
473 self.graph.add_edge(edge);
474 }
475 }
476 }
477
478 pub fn add_control_links(&mut self, links: &[ControlLinkInput]) {
480 if !self.config.include_control_links {
481 return;
482 }
483
484 for link in links {
485 let ctrl_id = *self
487 .control_nodes
488 .entry(link.control_id.clone())
489 .or_insert_with(|| {
490 let node = GraphNode::new(
491 0,
492 NodeType::Custom("Control".to_string()),
493 link.control_id.clone(),
494 link.control_name.clone(),
495 );
496 self.graph.add_node(node)
497 });
498
499 if let Some(&std_id) = self.standard_nodes.get(&link.standard_id) {
501 let edge = GraphEdge::new(
502 0,
503 ctrl_id,
504 std_id,
505 EdgeType::Custom("ImplementsStandard".to_string()),
506 )
507 .with_weight(1.0);
508 self.graph.add_edge(edge);
509 }
510 }
511 }
512
513 pub fn add_filings(&mut self, filings: &[FilingNodeInput]) {
515 for filing in filings {
516 if self.filing_nodes.contains_key(&filing.filing_id) {
517 continue;
518 }
519
520 let node = GraphNode::new(
521 0,
522 NodeType::Custom("Filing".to_string()),
523 filing.filing_id.clone(),
524 format!("{} [{}]", filing.filing_type, filing.company_code),
525 )
526 .with_categorical("filing_type", &filing.filing_type)
527 .with_categorical("status", &filing.status);
528
529 let filing_id = self.graph.add_node(node);
530 self.filing_nodes
531 .insert(filing.filing_id.clone(), filing_id);
532
533 if let Some(&jp_id) = self.jurisdiction_nodes.get(&filing.jurisdiction) {
535 let edge = GraphEdge::new(
536 0,
537 filing_id,
538 jp_id,
539 EdgeType::Custom("FilingForJurisdiction".to_string()),
540 )
541 .with_weight(1.0);
542 self.graph.add_edge(edge);
543 }
544
545 if self.config.include_company_links {
547 let company_id = *self
548 .company_nodes
549 .entry(filing.company_code.clone())
550 .or_insert_with(|| {
551 let node = GraphNode::new(
552 0,
553 NodeType::Company,
554 filing.company_code.clone(),
555 filing.company_code.clone(),
556 );
557 self.graph.add_node(node)
558 });
559
560 let edge = GraphEdge::new(
561 0,
562 filing_id,
563 company_id,
564 EdgeType::Custom("FiledByCompany".to_string()),
565 )
566 .with_weight(1.0);
567 self.graph.add_edge(edge);
568 }
569 }
570 }
571
572 pub fn build(mut self) -> Graph {
574 self.graph.metadata.node_count = self.graph.nodes.len();
575 self.graph.metadata.edge_count = self.graph.edges.len();
576 self.graph
577 }
578
579 pub fn standard_count(&self) -> usize {
581 self.standard_nodes.len()
582 }
583
584 pub fn jurisdiction_count(&self) -> usize {
586 self.jurisdiction_nodes.len()
587 }
588
589 pub fn account_count(&self) -> usize {
591 self.account_nodes.len()
592 }
593
594 pub fn control_count(&self) -> usize {
596 self.control_nodes.len()
597 }
598}
599
600#[cfg(test)]
601#[allow(clippy::unwrap_used)]
602mod tests {
603 use super::*;
604
605 fn make_standard(id: &str, title: &str) -> StandardNodeInput {
606 StandardNodeInput {
607 standard_id: id.to_string(),
608 title: title.to_string(),
609 category: "AccountingStandard".to_string(),
610 domain: "FinancialReporting".to_string(),
611 is_active: true,
612 features: vec![0.0, 0.0, 1.0, 0.85],
613 applicable_account_types: vec![],
614 applicable_processes: vec![],
615 }
616 }
617
618 #[test]
619 fn test_compliance_graph_builder() {
620 let mut builder = ComplianceGraphBuilder::new(ComplianceGraphConfig::default());
621
622 builder.add_standards(&[
623 make_standard("IFRS-16", "Leases"),
624 make_standard("ASC-842", "Leases"),
625 ]);
626
627 builder.add_jurisdictions(&[JurisdictionNodeInput {
628 country_code: "US".to_string(),
629 country_name: "United States".to_string(),
630 framework: "UsGaap".to_string(),
631 standard_count: 25,
632 tax_rate: 0.21,
633 }]);
634
635 builder.add_cross_references(&[CrossReferenceEdgeInput {
636 from_standard: "IFRS-16".to_string(),
637 to_standard: "ASC-842".to_string(),
638 relationship: "Related".to_string(),
639 convergence_level: 0.6,
640 }]);
641
642 builder.add_jurisdiction_mappings(&[JurisdictionMappingInput {
643 country_code: "US".to_string(),
644 standard_id: "ASC-842".to_string(),
645 }]);
646
647 let graph = builder.build();
648 assert_eq!(graph.nodes.len(), 3); assert_eq!(graph.edges.len(), 2); }
651
652 #[test]
653 fn test_cross_domain_account_links() {
654 let mut builder = ComplianceGraphBuilder::new(ComplianceGraphConfig::default());
655
656 builder.add_standards(&[StandardNodeInput {
657 standard_id: "IFRS-16".to_string(),
658 title: "Leases".to_string(),
659 category: "AccountingStandard".to_string(),
660 domain: "FinancialReporting".to_string(),
661 is_active: true,
662 features: vec![1.0],
663 applicable_account_types: vec!["Leases".to_string(), "ROUAsset".to_string()],
664 applicable_processes: vec!["R2R".to_string()],
665 }]);
666
667 builder.add_account_links(&[
668 AccountLinkInput {
669 standard_id: "IFRS-16".to_string(),
670 account_code: "1800".to_string(),
671 account_name: "ROU Assets".to_string(),
672 },
673 AccountLinkInput {
674 standard_id: "IFRS-16".to_string(),
675 account_code: "2800".to_string(),
676 account_name: "Lease Liabilities".to_string(),
677 },
678 ]);
679
680 let graph = builder.build();
681 assert_eq!(graph.nodes.len(), 3);
683 assert_eq!(graph.edges.len(), 2);
685 }
686
687 #[test]
688 fn test_cross_domain_control_links() {
689 let mut builder = ComplianceGraphBuilder::new(ComplianceGraphConfig::default());
690
691 builder.add_standards(&[make_standard("SOX-404", "ICFR Assessment")]);
692
693 builder.add_control_links(&[
694 ControlLinkInput {
695 standard_id: "SOX-404".to_string(),
696 control_id: "C010".to_string(),
697 control_name: "PO Approval Control".to_string(),
698 },
699 ControlLinkInput {
700 standard_id: "SOX-404".to_string(),
701 control_id: "C020".to_string(),
702 control_name: "Revenue Recognition Control".to_string(),
703 },
704 ]);
705
706 let graph = builder.build();
707 assert_eq!(graph.nodes.len(), 3);
709 assert_eq!(graph.edges.len(), 2);
711 }
712
713 #[test]
714 fn test_filing_with_company_links() {
715 let mut builder = ComplianceGraphBuilder::new(ComplianceGraphConfig::default());
716
717 builder.add_jurisdictions(&[JurisdictionNodeInput {
718 country_code: "US".to_string(),
719 country_name: "United States".to_string(),
720 framework: "UsGaap".to_string(),
721 standard_count: 25,
722 tax_rate: 0.21,
723 }]);
724
725 builder.add_filings(&[FilingNodeInput {
726 filing_id: "F001".to_string(),
727 filing_type: "10-K".to_string(),
728 company_code: "C001".to_string(),
729 jurisdiction: "US".to_string(),
730 status: "Filed".to_string(),
731 }]);
732
733 let graph = builder.build();
734 assert_eq!(graph.nodes.len(), 3);
736 assert_eq!(graph.edges.len(), 2);
738 }
739
740 #[test]
741 fn test_finding_cross_domain_edges() {
742 let mut builder = ComplianceGraphBuilder::new(ComplianceGraphConfig::default());
743
744 builder.add_standards(&[make_standard("SOX-404", "ICFR Assessment")]);
745
746 builder.add_control_links(&[ControlLinkInput {
748 standard_id: "SOX-404".to_string(),
749 control_id: "C010".to_string(),
750 control_name: "PO Approval".to_string(),
751 }]);
752 builder.add_account_links(&[AccountLinkInput {
753 standard_id: "SOX-404".to_string(),
754 account_code: "2000".to_string(),
755 account_name: "Accounts Payable".to_string(),
756 }]);
757
758 builder.add_findings(&[FindingNodeInput {
760 finding_id: "FIND-001".to_string(),
761 standard_id: "SOX-404".to_string(),
762 severity: "High".to_string(),
763 deficiency_level: "MaterialWeakness".to_string(),
764 severity_score: 1.0,
765 control_id: Some("C010".to_string()),
766 affected_accounts: vec!["2000".to_string()],
767 }]);
768
769 let graph = builder.build();
770 assert_eq!(graph.nodes.len(), 4);
772 assert_eq!(graph.edges.len(), 5);
775 }
776
777 #[test]
778 fn test_full_traversal_path() {
779 let mut builder = ComplianceGraphBuilder::new(ComplianceGraphConfig::default());
780
781 builder.add_standards(&[make_standard("IFRS-15", "Revenue")]);
783 builder.add_jurisdictions(&[JurisdictionNodeInput {
784 country_code: "DE".to_string(),
785 country_name: "Germany".to_string(),
786 framework: "LocalGaapWithIfrs".to_string(),
787 standard_count: 10,
788 tax_rate: 0.30,
789 }]);
790 builder.add_jurisdiction_mappings(&[JurisdictionMappingInput {
791 country_code: "DE".to_string(),
792 standard_id: "IFRS-15".to_string(),
793 }]);
794 builder.add_account_links(&[AccountLinkInput {
795 standard_id: "IFRS-15".to_string(),
796 account_code: "4000".to_string(),
797 account_name: "Revenue".to_string(),
798 }]);
799 builder.add_control_links(&[ControlLinkInput {
800 standard_id: "IFRS-15".to_string(),
801 control_id: "C020".to_string(),
802 control_name: "Revenue Recognition".to_string(),
803 }]);
804 builder.add_filings(&[FilingNodeInput {
805 filing_id: "F001".to_string(),
806 filing_type: "Jahresabschluss".to_string(),
807 company_code: "DE01".to_string(),
808 jurisdiction: "DE".to_string(),
809 status: "Filed".to_string(),
810 }]);
811
812 let graph = builder.build();
813 assert_eq!(graph.nodes.len(), 6);
816 assert_eq!(graph.edges.len(), 5);
819 }
820}