Skip to main content

datasynth_graph/builders/
compliance_graph.rs

1//! Compliance graph builder.
2//!
3//! Builds a graph layer with compliance-specific node and edge types, including
4//! cross-domain edges that link compliance standards to GL accounts, internal
5//! controls, and company entities.
6//!
7//! **Node types** (via `Custom`):
8//! - `Standard`: A compliance standard (IFRS 16, SOX 404, ISA 315, etc.)
9//! - `Jurisdiction`: A country jurisdiction profile
10//! - `AuditProcedure`: An audit procedure instance
11//! - `Finding`: A compliance finding
12//! - `Filing`: A regulatory filing
13//! - `Account`: A GL account (cross-domain from accounting layer)
14//! - `Control`: An internal control (cross-domain from governance layer)
15//! - `Company`: A company entity (cross-domain from entity layer)
16//!
17//! **Edge types** (via `Custom`):
18//! - `MapsToStandard`: Jurisdiction → Standard (mandatory mapping)
19//! - `CrossReference`: Standard ↔ Standard (convergence, related, etc.)
20//! - `Supersedes`: Standard → Standard (temporal supersession)
21//! - `TestsCompliance`: AuditProcedure → Standard
22//! - `FindingOnStandard`: Finding → Standard
23//! - `FilingForJurisdiction`: Filing → Jurisdiction
24//! - `GovernedByStandard`: Standard → Account (standard governs account treatment)
25//! - `ImplementsStandard`: Control → Standard (control implements standard)
26//! - `FiledByCompany`: Filing → Company
27//! - `FindingAffectsControl`: Finding → Control
28//! - `FindingAffectsAccount`: Finding → Account
29
30use std::collections::HashMap;
31
32use crate::models::{EdgeType, Graph, GraphEdge, GraphNode, GraphType, NodeId, NodeType};
33
34/// Configuration for compliance graph building.
35#[derive(Debug, Clone)]
36pub struct ComplianceGraphConfig {
37    /// Include compliance standard nodes.
38    pub include_standard_nodes: bool,
39    /// Include jurisdiction nodes.
40    pub include_jurisdiction_nodes: bool,
41    /// Include cross-reference edges between standards.
42    pub include_cross_references: bool,
43    /// Include supersession edges.
44    pub include_supersession_edges: bool,
45    /// Include edges linking standards to GL accounts.
46    pub include_account_links: bool,
47    /// Include edges linking standards to internal controls.
48    pub include_control_links: bool,
49    /// Include edges linking filings to companies.
50    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/// Input data for a compliance standard node.
68#[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    /// ML features: [category_code, domain_code, is_active, convergence_avg]
76    pub features: Vec<f64>,
77    /// GL account types this standard applies to.
78    pub applicable_account_types: Vec<String>,
79    /// Business processes this standard governs.
80    pub applicable_processes: Vec<String>,
81}
82
83/// Input data for a jurisdiction node.
84#[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/// Input data for a cross-reference edge.
94#[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/// Input data for a supersession edge.
103#[derive(Debug, Clone)]
104pub struct SupersessionEdgeInput {
105    pub old_standard: String,
106    pub new_standard: String,
107}
108
109/// Input data for a jurisdiction→standard mapping edge.
110#[derive(Debug, Clone)]
111pub struct JurisdictionMappingInput {
112    pub country_code: String,
113    pub standard_id: String,
114}
115
116/// Input data for an audit procedure node.
117#[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/// Input data for a finding node.
127#[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    /// Control ID where finding was identified (for cross-domain linking).
135    pub control_id: Option<String>,
136    /// Account codes affected by this finding.
137    pub affected_accounts: Vec<String>,
138}
139
140/// Input data for linking a standard to a GL account.
141#[derive(Debug, Clone)]
142pub struct AccountLinkInput {
143    pub standard_id: String,
144    pub account_code: String,
145    pub account_name: String,
146}
147
148/// Input data for linking a standard to an internal control.
149#[derive(Debug, Clone)]
150pub struct ControlLinkInput {
151    pub standard_id: String,
152    pub control_id: String,
153    pub control_name: String,
154}
155
156/// Input data for a filing node.
157#[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
166/// Builder for compliance regulatory graphs with cross-domain edges.
167pub struct ComplianceGraphBuilder {
168    config: ComplianceGraphConfig,
169    graph: Graph,
170    /// Map from standard_id to node ID.
171    standard_nodes: HashMap<String, NodeId>,
172    /// Map from country_code to node ID.
173    jurisdiction_nodes: HashMap<String, NodeId>,
174    /// Map from procedure_id to node ID.
175    procedure_nodes: HashMap<String, NodeId>,
176    /// Map from finding_id to node ID.
177    finding_nodes: HashMap<String, NodeId>,
178    /// Map from account_code to node ID (cross-domain).
179    account_nodes: HashMap<String, NodeId>,
180    /// Map from control_id to node ID (cross-domain).
181    control_nodes: HashMap<String, NodeId>,
182    /// Map from filing_id to node ID.
183    filing_nodes: HashMap<String, NodeId>,
184    /// Map from company_code to node ID (cross-domain).
185    company_nodes: HashMap<String, NodeId>,
186}
187
188impl ComplianceGraphBuilder {
189    /// Creates a new compliance graph builder.
190    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    /// Adds standard nodes.
209    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    /// Adds jurisdiction nodes.
247    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    /// Adds cross-reference edges between standards.
273    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    /// Adds supersession edges.
298    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    /// Adds jurisdiction→standard mapping edges.
322    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    /// Adds audit procedure nodes and links them to standards.
342    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            // Link to standard
363            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    /// Adds finding nodes and links them to standards, controls, and accounts.
378    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            // Link to standard
399            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            // Cross-domain: Finding → Control
412            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            // Cross-domain: Finding → affected Accounts
426            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    /// Adds GL account nodes and creates `GovernedByStandard` edges from standards.
444    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            // Ensure account node exists
451            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            // Standard → Account edge
465            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    /// Adds internal control nodes and creates `ImplementsStandard` edges.
479    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            // Ensure control node exists
486            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            // Control → Standard edge
500            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    /// Adds filing nodes with edges to jurisdictions and companies.
514    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            // Filing → Jurisdiction
534            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            // Filing → Company
546            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    /// Consumes the builder and returns the built graph.
573    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    /// Returns the number of standard nodes.
580    pub fn standard_count(&self) -> usize {
581        self.standard_nodes.len()
582    }
583
584    /// Returns the number of jurisdiction nodes.
585    pub fn jurisdiction_count(&self) -> usize {
586        self.jurisdiction_nodes.len()
587    }
588
589    /// Returns the number of account nodes (cross-domain).
590    pub fn account_count(&self) -> usize {
591        self.account_nodes.len()
592    }
593
594    /// Returns the number of control nodes (cross-domain).
595    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); // 2 standards + 1 jurisdiction
649        assert_eq!(graph.edges.len(), 2); // 1 cross-ref + 1 jurisdiction mapping
650    }
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        // 1 standard + 2 accounts
682        assert_eq!(graph.nodes.len(), 3);
683        // 2 GovernedByStandard edges
684        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        // 1 standard + 2 controls
708        assert_eq!(graph.nodes.len(), 3);
709        // 2 ImplementsStandard edges
710        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        // 1 jurisdiction + 1 filing + 1 company
735        assert_eq!(graph.nodes.len(), 3);
736        // 1 FilingForJurisdiction + 1 FiledByCompany
737        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        // Add control and account nodes first
747        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        // Now add finding that references the control and account
759        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        // 1 standard + 1 control + 1 account + 1 finding = 4
771        assert_eq!(graph.nodes.len(), 4);
772        // ImplementsStandard + GovernedByStandard + FindingOnStandard
773        //   + FindingAffectsControl + FindingAffectsAccount = 5
774        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        // Build: Company → Filing → Jurisdiction → Standard → Account + Control
782        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        // Company(DE01) + Filing(F001) + Jurisdiction(DE) + Standard(IFRS-15)
814        //   + Account(4000) + Control(C020) = 6 nodes
815        assert_eq!(graph.nodes.len(), 6);
816        // MapsToStandard + GovernedByStandard + ImplementsStandard
817        //   + FilingForJurisdiction + FiledByCompany = 5 edges
818        assert_eq!(graph.edges.len(), 5);
819    }
820}