1use std::collections::HashMap;
12
13use chrono::Datelike;
14use serde_json::Value;
15
16use datasynth_banking::models::{BankAccount, BankTransaction, BankingCustomer};
17use datasynth_core::models::audit::{
18 AnalyticalProcedureResult, AuditEngagement, AuditEvidence, AuditFinding, AuditProcedureStep,
19 AuditSample, ConfirmationResponse, ExternalConfirmation, InternalAuditFunction,
20 InternalAuditReport, ProfessionalJudgment, RelatedParty, RelatedPartyTransaction,
21 RiskAssessment, Workpaper,
22};
23use datasynth_core::models::compliance::{ComplianceFinding, ComplianceStandard, RegulatoryFiling};
24use datasynth_core::models::intercompany::{EliminationEntry, ICMatchedPair};
25use datasynth_core::models::sourcing::{
26 BidEvaluation, ProcurementContract, RfxEvent, SourcingProject, SupplierBid,
27 SupplierQualification,
28};
29use datasynth_core::models::ExpenseReport;
30use datasynth_core::models::{
31 BankReconciliation, CashForecast, CashPosition, ChartOfAccounts, ClimateScenario,
32 CosoComponent, CosoPrinciple, Customer, CycleCount, DebtInstrument, EarnedValueMetric,
33 EmissionRecord, Employee, EsgDisclosure, FixedAsset, HedgeRelationship, InternalControl,
34 JournalEntry, Material, OrganizationalEvent, PayrollRun, ProcessEvolutionEvent,
35 ProductionOrder, Project, ProjectMilestone, QualityInspection, SupplierEsgAssessment, TaxCode,
36 TaxJurisdiction, TaxLine, TaxProvision, TaxReturn, TimeEntry, Vendor, WithholdingTaxRecord,
37};
38use datasynth_generators::disruption::DisruptionEvent;
39
40use crate::models::hypergraph::{
41 AggregationStrategy, CrossLayerEdge, Hyperedge, HyperedgeParticipant, Hypergraph,
42 HypergraphLayer, HypergraphMetadata, HypergraphNode, NodeBudget, NodeBudgetReport,
43 NodeBudgetSuggestion,
44};
45
46const MONTH_END_DAY_THRESHOLD: u32 = 28;
48const WEEKDAY_NORMALIZER: f64 = 6.0;
50const DAY_OF_MONTH_NORMALIZER: f64 = 31.0;
52const MONTH_NORMALIZER: f64 = 12.0;
54
55#[allow(dead_code)]
59mod type_codes {
60 pub const ACCOUNT: u32 = 100;
62 pub const JOURNAL_ENTRY: u32 = 101;
63 pub const MATERIAL: u32 = 102;
64 pub const FIXED_ASSET: u32 = 103;
65 pub const COST_CENTER: u32 = 104;
66
67 pub const VENDOR: u32 = 200;
69 pub const CUSTOMER: u32 = 201;
70 pub const EMPLOYEE: u32 = 202;
71 pub const BANKING_CUSTOMER: u32 = 203;
72
73 pub const PURCHASE_ORDER: u32 = 300;
75 pub const GOODS_RECEIPT: u32 = 301;
76 pub const VENDOR_INVOICE: u32 = 302;
77 pub const PAYMENT: u32 = 303;
78 pub const SALES_ORDER: u32 = 310;
80 pub const DELIVERY: u32 = 311;
81 pub const CUSTOMER_INVOICE: u32 = 312;
82 pub const SOURCING_PROJECT: u32 = 320;
84 pub const RFX_EVENT: u32 = 321;
85 pub const SUPPLIER_BID: u32 = 322;
86 pub const BID_EVALUATION: u32 = 323;
87 pub const PROCUREMENT_CONTRACT: u32 = 324;
88 pub const SUPPLIER_QUALIFICATION: u32 = 325;
89 pub const PAYROLL_RUN: u32 = 330;
91 pub const TIME_ENTRY: u32 = 331;
92 pub const EXPENSE_REPORT: u32 = 332;
93 pub const PAYROLL_LINE_ITEM: u32 = 333;
94 pub const PRODUCTION_ORDER: u32 = 340;
96 pub const QUALITY_INSPECTION: u32 = 341;
97 pub const CYCLE_COUNT: u32 = 342;
98 pub const BANK_ACCOUNT: u32 = 350;
100 pub const BANK_TRANSACTION: u32 = 351;
101 pub const BANK_STATEMENT_LINE: u32 = 352;
102 pub const AUDIT_ENGAGEMENT: u32 = 360;
104 pub const WORKPAPER: u32 = 361;
105 pub const AUDIT_FINDING: u32 = 362;
106 pub const AUDIT_EVIDENCE: u32 = 363;
107 pub const RISK_ASSESSMENT: u32 = 364;
108 pub const PROFESSIONAL_JUDGMENT: u32 = 365;
109 pub const BANK_RECONCILIATION: u32 = 370;
111 pub const RECONCILING_ITEM: u32 = 372;
112 pub const OCPM_EVENT: u32 = 400;
114 pub const POOL_NODE: u32 = 399;
116
117 pub const COSO_COMPONENT: u32 = 500;
119 pub const COSO_PRINCIPLE: u32 = 501;
120 pub const SOX_ASSERTION: u32 = 502;
121 pub const INTERNAL_CONTROL: u32 = 503;
122 pub const KYC_PROFILE: u32 = 504;
123 pub const COMPLIANCE_STANDARD: u32 = 505;
124 pub const JURISDICTION: u32 = 506;
125 pub const REGULATORY_FILING: u32 = 507;
127 pub const COMPLIANCE_FINDING: u32 = 508;
128
129 pub const TAX_JURISDICTION: u32 = 410;
131 pub const TAX_CODE: u32 = 411;
132 pub const TAX_LINE: u32 = 412;
133 pub const TAX_RETURN: u32 = 413;
134 pub const TAX_PROVISION: u32 = 414;
135 pub const WITHHOLDING_TAX: u32 = 415;
136
137 pub const CASH_POSITION: u32 = 420;
139 pub const CASH_FORECAST: u32 = 421;
140 pub const HEDGE_RELATIONSHIP: u32 = 422;
141 pub const DEBT_INSTRUMENT: u32 = 423;
142
143 pub const EMISSION_RECORD: u32 = 430;
145 pub const ESG_DISCLOSURE: u32 = 431;
146 pub const SUPPLIER_ESG_ASSESSMENT: u32 = 432;
147 pub const CLIMATE_SCENARIO: u32 = 433;
148
149 pub const PROJECT: u32 = 451;
151 pub const EARNED_VALUE: u32 = 452;
152 pub const PROJECT_MILESTONE: u32 = 454;
153
154 pub const IC_MATCHED_PAIR: u32 = 460;
156 pub const ELIMINATION_ENTRY: u32 = 461;
157
158 pub const PROCESS_EVOLUTION: u32 = 470;
160 pub const ORGANIZATIONAL_EVENT: u32 = 471;
161 pub const DISRUPTION_EVENT: u32 = 472;
162
163 pub const AML_ALERT: u32 = 505;
165 pub const EXTERNAL_CONFIRMATION: u32 = 366;
169 pub const CONFIRMATION_RESPONSE: u32 = 367;
170 pub const AUDIT_PROCEDURE_STEP: u32 = 368;
171 pub const AUDIT_SAMPLE: u32 = 369;
172 pub const ANALYTICAL_PROCEDURE_RESULT: u32 = 375;
173 pub const INTERNAL_AUDIT_FUNCTION: u32 = 376;
174 pub const INTERNAL_AUDIT_REPORT: u32 = 377;
175 pub const RELATED_PARTY: u32 = 378;
176 pub const RELATED_PARTY_TRANSACTION: u32 = 379;
177
178 pub const IMPLEMENTS_CONTROL: u32 = 40;
180 pub const GOVERNED_BY_STANDARD: u32 = 41;
181 pub const OWNS_CONTROL: u32 = 42;
182 pub const OVERSEE_PROCESS: u32 = 43;
183 pub const ENFORCES_ASSERTION: u32 = 44;
184 pub const STANDARD_TO_CONTROL: u32 = 45;
185 pub const FINDING_ON_CONTROL: u32 = 46;
186 pub const STANDARD_TO_ACCOUNT: u32 = 47;
187 pub const SUPPLIES_TO: u32 = 48;
188 pub const FILED_BY_COMPANY: u32 = 49;
189 pub const COVERS_COSO_PRINCIPLE: u32 = 54;
190 pub const CONTAINS_ACCOUNT: u32 = 55;
191
192 pub const CONFIRMATION_FOR_ACCOUNT: u32 = 138;
194 pub const CONFIRMATION_RESPONSE_EDGE: u32 = 139;
195 pub const CONFIRMATION_IN_WORKPAPER: u32 = 140;
196 pub const STEP_IN_WORKPAPER: u32 = 141;
197 pub const STEP_USES_SAMPLE: u32 = 142;
198 pub const STEP_EVIDENCE: u32 = 143;
199 pub const SAMPLE_FROM_WORKPAPER: u32 = 144;
200 pub const AP_FOR_ACCOUNT: u32 = 145;
201 pub const AP_IN_WORKPAPER: u32 = 146;
202 pub const IAF_FOR_ENGAGEMENT: u32 = 147;
203 pub const REPORT_FROM_IAF: u32 = 148;
204 pub const IA_REPORT_FOR_ENGAGEMENT: u32 = 149;
205 pub const RP_FOR_ENGAGEMENT: u32 = 150;
206 pub const RPT_WITH_PARTY: u32 = 151;
207 pub const RPT_JOURNAL_ENTRY: u32 = 152;
208}
209
210#[derive(Debug, Clone)]
212pub struct HypergraphConfig {
213 pub max_nodes: usize,
215 pub aggregation_strategy: AggregationStrategy,
217 pub include_coso: bool,
219 pub include_controls: bool,
220 pub include_sox: bool,
221 pub include_vendors: bool,
222 pub include_customers: bool,
223 pub include_employees: bool,
224 pub include_p2p: bool,
226 pub include_o2c: bool,
227 pub include_s2c: bool,
228 pub include_h2r: bool,
229 pub include_mfg: bool,
230 pub include_bank: bool,
231 pub include_audit: bool,
232 pub include_compliance: bool,
233 pub include_r2r: bool,
234 pub include_tax: bool,
235 pub include_treasury: bool,
236 pub include_esg: bool,
237 pub include_project: bool,
238 pub include_intercompany: bool,
239 pub include_temporal_events: bool,
240 pub events_as_hyperedges: bool,
241 pub docs_per_counterparty_threshold: usize,
243 pub include_accounts: bool,
245 pub je_as_hyperedges: bool,
246 pub include_cross_layer_edges: bool,
248}
249
250impl Default for HypergraphConfig {
251 fn default() -> Self {
252 Self {
253 max_nodes: 50_000,
254 aggregation_strategy: AggregationStrategy::PoolByCounterparty,
255 include_coso: true,
256 include_controls: true,
257 include_sox: true,
258 include_vendors: true,
259 include_customers: true,
260 include_employees: true,
261 include_p2p: true,
262 include_o2c: true,
263 include_s2c: true,
264 include_h2r: true,
265 include_mfg: true,
266 include_bank: true,
267 include_audit: true,
268 include_compliance: true,
269 include_r2r: true,
270 include_tax: true,
271 include_treasury: true,
272 include_esg: true,
273 include_project: true,
274 include_intercompany: true,
275 include_temporal_events: true,
276 events_as_hyperedges: true,
277 docs_per_counterparty_threshold: 20,
278 include_accounts: true,
279 je_as_hyperedges: true,
280 include_cross_layer_edges: true,
281 }
282 }
283}
284
285#[derive(Debug, Clone, Default)]
291pub struct LayerDemand {
292 pub l1: usize,
294 pub l2: usize,
296 pub l3: usize,
298}
299
300#[derive(Default)]
308pub struct BuilderInput<'a> {
309 pub controls: &'a [InternalControl],
312 pub vendors: &'a [Vendor],
314 pub customers: &'a [Customer],
316 pub employees: &'a [Employee],
318 pub materials: &'a [Material],
320 pub fixed_assets: &'a [FixedAsset],
322 pub compliance_standards: &'a [ComplianceStandard],
324 pub compliance_findings: &'a [ComplianceFinding],
326 pub regulatory_filings: &'a [RegulatoryFiling],
328 pub emissions: &'a [EmissionRecord],
330 pub esg_disclosures: &'a [EsgDisclosure],
332 pub supplier_esg_assessments: &'a [SupplierEsgAssessment],
334 pub climate_scenarios: &'a [ClimateScenario],
336
337 pub audit_engagements: &'a [AuditEngagement],
340 pub workpapers: &'a [Workpaper],
342 pub audit_findings: &'a [AuditFinding],
344 pub audit_evidence: &'a [AuditEvidence],
346 pub risk_assessments: &'a [RiskAssessment],
348 pub professional_judgments: &'a [ProfessionalJudgment],
350 pub external_confirmations: &'a [ExternalConfirmation],
352 pub confirmation_responses: &'a [ConfirmationResponse],
354 pub audit_procedure_steps: &'a [AuditProcedureStep],
356 pub audit_samples: &'a [AuditSample],
358 pub analytical_procedure_results: &'a [AnalyticalProcedureResult],
360 pub internal_audit_functions: &'a [InternalAuditFunction],
362 pub internal_audit_reports: &'a [InternalAuditReport],
364 pub related_parties: &'a [RelatedParty],
366 pub related_party_transactions: &'a [RelatedPartyTransaction],
368
369 pub purchase_orders: &'a [datasynth_core::models::documents::PurchaseOrder],
372 pub goods_receipts: &'a [datasynth_core::models::documents::GoodsReceipt],
374 pub vendor_invoices: &'a [datasynth_core::models::documents::VendorInvoice],
376 pub payments: &'a [datasynth_core::models::documents::Payment],
378 pub sales_orders: &'a [datasynth_core::models::documents::SalesOrder],
380 pub deliveries: &'a [datasynth_core::models::documents::Delivery],
382 pub customer_invoices: &'a [datasynth_core::models::documents::CustomerInvoice],
384 pub sourcing_projects: &'a [SourcingProject],
386 pub supplier_qualifications: &'a [SupplierQualification],
388 pub rfx_events: &'a [RfxEvent],
390 pub supplier_bids: &'a [SupplierBid],
392 pub bid_evaluations: &'a [BidEvaluation],
394 pub procurement_contracts: &'a [ProcurementContract],
396 pub payroll_runs: &'a [PayrollRun],
398 pub time_entries: &'a [TimeEntry],
400 pub expense_reports: &'a [ExpenseReport],
402 pub production_orders: &'a [ProductionOrder],
404 pub quality_inspections: &'a [QualityInspection],
406 pub cycle_counts: &'a [CycleCount],
408 pub banking_customers: &'a [BankingCustomer],
410 pub bank_accounts: &'a [BankAccount],
412 pub bank_transactions: &'a [BankTransaction],
414 pub bank_reconciliations: &'a [BankReconciliation],
416 pub process_evolution_events: &'a [ProcessEvolutionEvent],
418 pub organizational_events: &'a [OrganizationalEvent],
420 pub disruption_events: &'a [DisruptionEvent],
422 pub ic_matched_pairs: &'a [ICMatchedPair],
424 pub elimination_entries: &'a [EliminationEntry],
426 pub ocpm_event_log: Option<&'a datasynth_ocpm::OcpmEventLog>,
428
429 pub chart_of_accounts: Option<&'a ChartOfAccounts>,
432 pub journal_entries: &'a [JournalEntry],
434
435 pub tax_jurisdictions: &'a [TaxJurisdiction],
438 pub tax_codes: &'a [TaxCode],
440 pub tax_lines: &'a [TaxLine],
442 pub tax_returns: &'a [TaxReturn],
444 pub tax_provisions: &'a [TaxProvision],
446 pub withholding_records: &'a [WithholdingTaxRecord],
448 pub cash_positions: &'a [CashPosition],
450 pub cash_forecasts: &'a [CashForecast],
452 pub hedge_relationships: &'a [HedgeRelationship],
454 pub debt_instruments: &'a [DebtInstrument],
456 pub projects: &'a [Project],
458 pub earned_value_metrics: &'a [EarnedValueMetric],
460 pub project_milestones: &'a [ProjectMilestone],
462}
463
464pub struct HypergraphBuilder {
466 config: HypergraphConfig,
467 budget: NodeBudget,
468 nodes: Vec<HypergraphNode>,
469 edges: Vec<CrossLayerEdge>,
470 hyperedges: Vec<Hyperedge>,
471 node_index: HashMap<String, usize>,
473 aggregate_count: usize,
475 control_node_ids: HashMap<String, String>,
477 coso_component_ids: HashMap<String, String>,
479 account_node_ids: HashMap<String, String>,
481 vendor_node_ids: HashMap<String, String>,
483 customer_node_ids: HashMap<String, String>,
485 employee_node_ids: HashMap<String, String>,
487 doc_counterparty_links: Vec<(String, String, String)>, standard_node_ids: HashMap<String, String>,
492 compliance_finding_control_links: Vec<(String, String)>, #[allow(dead_code)]
496 standard_account_links: Vec<(String, String)>, }
498
499impl HypergraphBuilder {
500 pub fn new(config: HypergraphConfig) -> Self {
502 let budget = NodeBudget::new(config.max_nodes);
503 Self {
504 config,
505 budget,
506 nodes: Vec::new(),
507 edges: Vec::new(),
508 hyperedges: Vec::new(),
509 node_index: HashMap::new(),
510 aggregate_count: 0,
511 control_node_ids: HashMap::new(),
512 coso_component_ids: HashMap::new(),
513 account_node_ids: HashMap::new(),
514 vendor_node_ids: HashMap::new(),
515 customer_node_ids: HashMap::new(),
516 employee_node_ids: HashMap::new(),
517 doc_counterparty_links: Vec::new(),
518 standard_node_ids: HashMap::new(),
519 compliance_finding_control_links: Vec::new(),
520 standard_account_links: Vec::new(),
521 }
522 }
523
524 pub fn rebalance_budget(&mut self, l1_demand: usize, l2_demand: usize, l3_demand: usize) {
530 self.budget.rebalance(l1_demand, l2_demand, l3_demand);
531 }
532
533 pub fn suggest_budget(&self, demand: &LayerDemand) -> NodeBudgetSuggestion {
538 self.budget.suggest(demand.l1, demand.l2, demand.l3)
539 }
540
541 pub fn rebalance_with_demand(&mut self, demand: &LayerDemand) {
546 self.budget.rebalance(demand.l1, demand.l2, demand.l3);
547 }
548
549 pub fn budget(&self) -> &NodeBudget {
551 &self.budget
552 }
553
554 pub fn count_demand(input: &BuilderInput<'_>) -> LayerDemand {
560 let coso_count = 22;
562
563 let l1 = coso_count
565 + input.controls.len()
566 + input.vendors.len()
567 + input.customers.len()
568 + input.employees.len()
569 + input.materials.len()
570 + input.fixed_assets.len()
571 + input.compliance_standards.len()
572 + input.emissions.len()
573 + input.esg_disclosures.len()
574 + input.supplier_esg_assessments.len()
575 + input.climate_scenarios.len();
576
577 let ocpm_count = input
580 .ocpm_event_log
581 .map(|log| log.events.len())
582 .unwrap_or(0);
583 let l2 = input.audit_engagements.len()
584 + input.workpapers.len()
585 + input.audit_findings.len()
586 + input.audit_evidence.len()
587 + input.risk_assessments.len()
588 + input.professional_judgments.len()
589 + input.external_confirmations.len()
590 + input.confirmation_responses.len()
591 + input.audit_procedure_steps.len()
592 + input.audit_samples.len()
593 + input.analytical_procedure_results.len()
594 + input.internal_audit_functions.len()
595 + input.internal_audit_reports.len()
596 + input.related_parties.len()
597 + input.related_party_transactions.len()
598 + input.purchase_orders.len()
599 + input.goods_receipts.len()
600 + input.vendor_invoices.len()
601 + input.payments.len()
602 + input.sales_orders.len()
603 + input.deliveries.len()
604 + input.customer_invoices.len()
605 + input.sourcing_projects.len()
606 + input.supplier_qualifications.len()
607 + input.rfx_events.len()
608 + input.supplier_bids.len()
609 + input.bid_evaluations.len()
610 + input.procurement_contracts.len()
611 + input.payroll_runs.len()
612 + input.time_entries.len()
613 + input.expense_reports.len()
614 + input.production_orders.len()
615 + input.quality_inspections.len()
616 + input.cycle_counts.len()
617 + input.banking_customers.len()
618 + input.bank_accounts.len()
619 + input.bank_transactions.len()
620 + input.bank_reconciliations.len()
621 + input.compliance_findings.len()
622 + input.regulatory_filings.len()
623 + input.process_evolution_events.len()
624 + input.organizational_events.len()
625 + input.disruption_events.len()
626 + input.ic_matched_pairs.len()
627 + input.elimination_entries.len()
628 + ocpm_count;
629
630 let account_count = input
632 .chart_of_accounts
633 .map(|coa| coa.accounts.len())
634 .unwrap_or(0);
635 let l3 = account_count
636 + input.journal_entries.len()
637 + input.tax_jurisdictions.len()
638 + input.tax_codes.len()
639 + input.tax_lines.len()
640 + input.tax_returns.len()
641 + input.tax_provisions.len()
642 + input.withholding_records.len()
643 + input.cash_positions.len()
644 + input.cash_forecasts.len()
645 + input.hedge_relationships.len()
646 + input.debt_instruments.len()
647 + input.projects.len()
648 + input.earned_value_metrics.len()
649 + input.project_milestones.len();
650
651 LayerDemand { l1, l2, l3 }
652 }
653
654 pub fn add_all_ordered(&mut self, input: &BuilderInput<'_>) {
668 self.add_coso_framework();
670 self.add_controls(input.controls);
671 self.add_vendors(input.vendors);
672 self.add_customers(input.customers);
673 self.add_employees(input.employees);
674 self.add_materials(input.materials);
675 self.add_fixed_assets(input.fixed_assets);
676 self.add_compliance_regulations(
677 input.compliance_standards,
678 input.compliance_findings,
679 input.regulatory_filings,
680 );
681 self.add_esg_documents(
682 input.emissions,
683 input.esg_disclosures,
684 input.supplier_esg_assessments,
685 input.climate_scenarios,
686 );
687
688 self.add_audit_documents(
690 input.audit_engagements,
691 input.workpapers,
692 input.audit_findings,
693 input.audit_evidence,
694 input.risk_assessments,
695 input.professional_judgments,
696 );
697 self.add_audit_procedure_entities(
698 input.external_confirmations,
699 input.confirmation_responses,
700 input.audit_procedure_steps,
701 input.audit_samples,
702 input.analytical_procedure_results,
703 input.internal_audit_functions,
704 input.internal_audit_reports,
705 input.related_parties,
706 input.related_party_transactions,
707 );
708
709 self.add_p2p_documents(
711 input.purchase_orders,
712 input.goods_receipts,
713 input.vendor_invoices,
714 input.payments,
715 );
716 self.add_o2c_documents(
717 input.sales_orders,
718 input.deliveries,
719 input.customer_invoices,
720 );
721 self.add_s2c_documents(
722 input.sourcing_projects,
723 input.supplier_qualifications,
724 input.rfx_events,
725 input.supplier_bids,
726 input.bid_evaluations,
727 input.procurement_contracts,
728 );
729 self.add_h2r_documents(
730 input.payroll_runs,
731 input.time_entries,
732 input.expense_reports,
733 );
734 self.add_mfg_documents(
735 input.production_orders,
736 input.quality_inspections,
737 input.cycle_counts,
738 );
739 self.add_bank_documents(
740 input.banking_customers,
741 input.bank_accounts,
742 input.bank_transactions,
743 );
744 self.add_aml_alerts(input.bank_transactions);
745 self.add_kyc_profiles(input.banking_customers);
746 self.add_bank_recon_documents(input.bank_reconciliations);
747 self.add_temporal_events(
748 input.process_evolution_events,
749 input.organizational_events,
750 input.disruption_events,
751 );
752 self.add_intercompany_documents(input.ic_matched_pairs, input.elimination_entries);
753 if let Some(ocpm) = input.ocpm_event_log {
754 self.add_ocpm_events(ocpm);
755 }
756
757 if let Some(coa) = input.chart_of_accounts {
759 self.add_accounts(coa);
760 }
761 if self.config.je_as_hyperedges {
762 self.add_journal_entries_as_hyperedges(input.journal_entries);
763 } else {
764 self.add_journal_entry_nodes(input.journal_entries);
765 }
766
767 self.add_tax_documents(
769 input.tax_jurisdictions,
770 input.tax_codes,
771 input.tax_lines,
772 input.tax_returns,
773 input.tax_provisions,
774 input.withholding_records,
775 );
776 self.add_treasury_documents(
777 input.cash_positions,
778 input.cash_forecasts,
779 input.hedge_relationships,
780 input.debt_instruments,
781 );
782 self.add_project_documents(
783 input.projects,
784 input.earned_value_metrics,
785 input.project_milestones,
786 );
787
788 self.tag_process_family();
790 }
791
792 pub fn add_coso_framework(&mut self) {
794 if !self.config.include_coso {
795 return;
796 }
797
798 let components = [
799 (CosoComponent::ControlEnvironment, "Control Environment"),
800 (CosoComponent::RiskAssessment, "Risk Assessment"),
801 (CosoComponent::ControlActivities, "Control Activities"),
802 (
803 CosoComponent::InformationCommunication,
804 "Information & Communication",
805 ),
806 (CosoComponent::MonitoringActivities, "Monitoring Activities"),
807 ];
808
809 for (component, name) in &components {
810 let id = format!("coso_comp_{}", name.replace(' ', "_").replace('&', "and"));
811 if self.try_add_node(HypergraphNode {
812 id: id.clone(),
813 entity_type: "coso_component".to_string(),
814 entity_type_code: type_codes::COSO_COMPONENT,
815 layer: HypergraphLayer::GovernanceControls,
816 external_id: format!("{component:?}"),
817 label: name.to_string(),
818 properties: HashMap::new(),
819 features: vec![component_to_feature(component)],
820 is_anomaly: false,
821 anomaly_type: None,
822 is_aggregate: false,
823 aggregate_count: 0,
824 }) {
825 self.coso_component_ids.insert(format!("{component:?}"), id);
826 }
827 }
828
829 let principles = [
830 (
831 CosoPrinciple::IntegrityAndEthics,
832 "Integrity and Ethics",
833 CosoComponent::ControlEnvironment,
834 ),
835 (
836 CosoPrinciple::BoardOversight,
837 "Board Oversight",
838 CosoComponent::ControlEnvironment,
839 ),
840 (
841 CosoPrinciple::OrganizationalStructure,
842 "Organizational Structure",
843 CosoComponent::ControlEnvironment,
844 ),
845 (
846 CosoPrinciple::CommitmentToCompetence,
847 "Commitment to Competence",
848 CosoComponent::ControlEnvironment,
849 ),
850 (
851 CosoPrinciple::Accountability,
852 "Accountability",
853 CosoComponent::ControlEnvironment,
854 ),
855 (
856 CosoPrinciple::ClearObjectives,
857 "Clear Objectives",
858 CosoComponent::RiskAssessment,
859 ),
860 (
861 CosoPrinciple::IdentifyRisks,
862 "Identify Risks",
863 CosoComponent::RiskAssessment,
864 ),
865 (
866 CosoPrinciple::FraudRisk,
867 "Fraud Risk",
868 CosoComponent::RiskAssessment,
869 ),
870 (
871 CosoPrinciple::ChangeIdentification,
872 "Change Identification",
873 CosoComponent::RiskAssessment,
874 ),
875 (
876 CosoPrinciple::ControlActions,
877 "Control Actions",
878 CosoComponent::ControlActivities,
879 ),
880 (
881 CosoPrinciple::TechnologyControls,
882 "Technology Controls",
883 CosoComponent::ControlActivities,
884 ),
885 (
886 CosoPrinciple::PoliciesAndProcedures,
887 "Policies and Procedures",
888 CosoComponent::ControlActivities,
889 ),
890 (
891 CosoPrinciple::QualityInformation,
892 "Quality Information",
893 CosoComponent::InformationCommunication,
894 ),
895 (
896 CosoPrinciple::InternalCommunication,
897 "Internal Communication",
898 CosoComponent::InformationCommunication,
899 ),
900 (
901 CosoPrinciple::ExternalCommunication,
902 "External Communication",
903 CosoComponent::InformationCommunication,
904 ),
905 (
906 CosoPrinciple::OngoingMonitoring,
907 "Ongoing Monitoring",
908 CosoComponent::MonitoringActivities,
909 ),
910 (
911 CosoPrinciple::DeficiencyEvaluation,
912 "Deficiency Evaluation",
913 CosoComponent::MonitoringActivities,
914 ),
915 ];
916
917 for (principle, name, parent_component) in &principles {
918 let principle_id = format!("coso_prin_{}", name.replace(' ', "_").replace('&', "and"));
919 if self.try_add_node(HypergraphNode {
920 id: principle_id.clone(),
921 entity_type: "coso_principle".to_string(),
922 entity_type_code: type_codes::COSO_PRINCIPLE,
923 layer: HypergraphLayer::GovernanceControls,
924 external_id: format!("{principle:?}"),
925 label: name.to_string(),
926 properties: {
927 let mut p = HashMap::new();
928 p.insert(
929 "principle_number".to_string(),
930 Value::Number(principle.principle_number().into()),
931 );
932 p
933 },
934 features: vec![principle.principle_number() as f64],
935 is_anomaly: false,
936 anomaly_type: None,
937 is_aggregate: false,
938 aggregate_count: 0,
939 }) {
940 let comp_key = format!("{parent_component:?}");
942 if let Some(comp_id) = self.coso_component_ids.get(&comp_key) {
943 self.edges.push(CrossLayerEdge {
944 source_id: principle_id,
945 source_layer: HypergraphLayer::GovernanceControls,
946 target_id: comp_id.clone(),
947 target_layer: HypergraphLayer::GovernanceControls,
948 edge_type: "CoversCosoPrinciple".to_string(),
949 edge_type_code: type_codes::COVERS_COSO_PRINCIPLE,
950 properties: HashMap::new(),
951 });
952 }
953 }
954 }
955 }
956
957 pub fn add_controls(&mut self, controls: &[InternalControl]) {
959 if !self.config.include_controls {
960 return;
961 }
962
963 for control in controls {
964 let node_id = format!("ctrl_{}", control.control_id);
965 if self.try_add_node(HypergraphNode {
966 id: node_id.clone(),
967 entity_type: "internal_control".to_string(),
968 entity_type_code: type_codes::INTERNAL_CONTROL,
969 layer: HypergraphLayer::GovernanceControls,
970 external_id: control.control_id.clone(),
971 label: control.control_name.clone(),
972 properties: {
973 let mut p = HashMap::new();
974 p.insert(
975 "control_type".to_string(),
976 Value::String(format!("{:?}", control.control_type)),
977 );
978 p.insert(
979 "risk_level".to_string(),
980 Value::String(format!("{:?}", control.risk_level)),
981 );
982 p.insert(
983 "is_key_control".to_string(),
984 Value::Bool(control.is_key_control),
985 );
986 p.insert(
987 "maturity_level".to_string(),
988 Value::String(format!("{:?}", control.maturity_level)),
989 );
990 p.insert(
991 "description".to_string(),
992 Value::String(control.description.clone()),
993 );
994 p.insert(
995 "objective".to_string(),
996 Value::String(control.objective.clone()),
997 );
998 p.insert(
999 "frequency".to_string(),
1000 Value::String(format!("{}", control.frequency).to_lowercase()),
1001 );
1002 p.insert(
1003 "owner".to_string(),
1004 Value::String(format!("{}", control.owner_role)),
1005 );
1006 p.insert(
1007 "coso_component".to_string(),
1008 Value::String(format!("{:?}", control.coso_component)),
1009 );
1010 p.insert(
1011 "sox_assertion".to_string(),
1012 Value::String(format!("{:?}", control.sox_assertion)),
1013 );
1014 p.insert(
1015 "control_scope".to_string(),
1016 Value::String(format!("{:?}", control.control_scope)),
1017 );
1018 p
1019 },
1020 features: vec![
1021 if control.is_key_control { 1.0 } else { 0.0 },
1022 control.maturity_level.level() as f64 / 5.0,
1023 ],
1024 is_anomaly: false,
1025 anomaly_type: None,
1026 is_aggregate: false,
1027 aggregate_count: 0,
1028 }) {
1029 self.control_node_ids
1030 .insert(control.control_id.clone(), node_id.clone());
1031
1032 let comp_key = format!("{:?}", control.coso_component);
1034 if let Some(comp_id) = self.coso_component_ids.get(&comp_key) {
1035 self.edges.push(CrossLayerEdge {
1036 source_id: node_id.clone(),
1037 source_layer: HypergraphLayer::GovernanceControls,
1038 target_id: comp_id.clone(),
1039 target_layer: HypergraphLayer::GovernanceControls,
1040 edge_type: "ImplementsControl".to_string(),
1041 edge_type_code: type_codes::IMPLEMENTS_CONTROL,
1042 properties: HashMap::new(),
1043 });
1044 }
1045
1046 if self.config.include_sox {
1048 let assertion_id = format!("sox_{:?}", control.sox_assertion).to_lowercase();
1049 if !self.node_index.contains_key(&assertion_id) {
1051 self.try_add_node(HypergraphNode {
1052 id: assertion_id.clone(),
1053 entity_type: "sox_assertion".to_string(),
1054 entity_type_code: type_codes::SOX_ASSERTION,
1055 layer: HypergraphLayer::GovernanceControls,
1056 external_id: format!("{:?}", control.sox_assertion),
1057 label: format!("{:?}", control.sox_assertion),
1058 properties: HashMap::new(),
1059 features: vec![],
1060 is_anomaly: false,
1061 anomaly_type: None,
1062 is_aggregate: false,
1063 aggregate_count: 0,
1064 });
1065 }
1066 self.edges.push(CrossLayerEdge {
1067 source_id: node_id,
1068 source_layer: HypergraphLayer::GovernanceControls,
1069 target_id: assertion_id,
1070 target_layer: HypergraphLayer::GovernanceControls,
1071 edge_type: "EnforcesAssertion".to_string(),
1072 edge_type_code: type_codes::ENFORCES_ASSERTION,
1073 properties: HashMap::new(),
1074 });
1075 }
1076 }
1077 }
1078 }
1079
1080 pub fn add_vendors(&mut self, vendors: &[Vendor]) {
1082 if !self.config.include_vendors {
1083 return;
1084 }
1085
1086 for vendor in vendors {
1087 let node_id = format!("vnd_{}", vendor.vendor_id);
1088 if self.try_add_node(HypergraphNode {
1089 id: node_id.clone(),
1090 entity_type: "vendor".to_string(),
1091 entity_type_code: type_codes::VENDOR,
1092 layer: HypergraphLayer::GovernanceControls,
1093 external_id: vendor.vendor_id.clone(),
1094 label: vendor.name.clone(),
1095 properties: {
1096 let mut p = HashMap::new();
1097 p.insert(
1098 "vendor_type".to_string(),
1099 Value::String(format!("{:?}", vendor.vendor_type)),
1100 );
1101 p.insert("country".to_string(), Value::String(vendor.country.clone()));
1102 p.insert("is_active".to_string(), Value::Bool(vendor.is_active));
1103 p
1104 },
1105 features: vec![if vendor.is_active { 1.0 } else { 0.0 }],
1106 is_anomaly: false,
1107 anomaly_type: None,
1108 is_aggregate: false,
1109 aggregate_count: 0,
1110 }) {
1111 self.vendor_node_ids
1112 .insert(vendor.vendor_id.clone(), node_id);
1113 }
1114 }
1115 }
1116
1117 pub fn add_customers(&mut self, customers: &[Customer]) {
1119 if !self.config.include_customers {
1120 return;
1121 }
1122
1123 for customer in customers {
1124 let node_id = format!("cust_{}", customer.customer_id);
1125 if self.try_add_node(HypergraphNode {
1126 id: node_id.clone(),
1127 entity_type: "customer".to_string(),
1128 entity_type_code: type_codes::CUSTOMER,
1129 layer: HypergraphLayer::GovernanceControls,
1130 external_id: customer.customer_id.clone(),
1131 label: customer.name.clone(),
1132 properties: {
1133 let mut p = HashMap::new();
1134 p.insert(
1135 "customer_type".to_string(),
1136 Value::String(format!("{:?}", customer.customer_type)),
1137 );
1138 p.insert(
1139 "country".to_string(),
1140 Value::String(customer.country.clone()),
1141 );
1142 p.insert(
1143 "credit_rating".to_string(),
1144 Value::String(format!("{:?}", customer.credit_rating)),
1145 );
1146 p
1147 },
1148 features: vec![if customer.is_active { 1.0 } else { 0.0 }],
1149 is_anomaly: false,
1150 anomaly_type: None,
1151 is_aggregate: false,
1152 aggregate_count: 0,
1153 }) {
1154 self.customer_node_ids
1155 .insert(customer.customer_id.clone(), node_id);
1156 }
1157 }
1158 }
1159
1160 pub fn add_employees(&mut self, employees: &[Employee]) {
1162 if !self.config.include_employees {
1163 return;
1164 }
1165
1166 for employee in employees {
1167 let node_id = format!("emp_{}", employee.employee_id);
1168 if self.try_add_node(HypergraphNode {
1169 id: node_id.clone(),
1170 entity_type: "employee".to_string(),
1171 entity_type_code: type_codes::EMPLOYEE,
1172 layer: HypergraphLayer::GovernanceControls,
1173 external_id: employee.employee_id.clone(),
1174 label: employee.display_name.clone(),
1175 properties: {
1176 let mut p = HashMap::new();
1177 p.insert(
1178 "persona".to_string(),
1179 Value::String(employee.persona.to_string()),
1180 );
1181 p.insert(
1182 "job_level".to_string(),
1183 Value::String(format!("{:?}", employee.job_level)),
1184 );
1185 p.insert(
1186 "company_code".to_string(),
1187 Value::String(employee.company_code.clone()),
1188 );
1189 p.insert("email".to_string(), Value::String(employee.email.clone()));
1190 p.insert(
1191 "department".to_string(),
1192 Value::String(employee.department_id.clone().unwrap_or_default()),
1193 );
1194 p.insert(
1195 "job_title".to_string(),
1196 Value::String(employee.job_title.clone()),
1197 );
1198 p.insert(
1199 "status".to_string(),
1200 Value::String(format!("{:?}", employee.status)),
1201 );
1202 p
1203 },
1204 features: vec![employee
1205 .approval_limit
1206 .to_string()
1207 .parse::<f64>()
1208 .unwrap_or(0.0)
1209 .ln_1p()],
1210 is_anomaly: false,
1211 anomaly_type: None,
1212 is_aggregate: false,
1213 aggregate_count: 0,
1214 }) {
1215 self.employee_node_ids
1216 .insert(employee.employee_id.clone(), node_id);
1217 }
1218 }
1219 }
1220
1221 pub fn add_materials(&mut self, materials: &[Material]) {
1223 for mat in materials {
1224 let node_id = format!("mat_{}", mat.material_id);
1225 self.try_add_node(HypergraphNode {
1226 id: node_id,
1227 entity_type: "material".to_string(),
1228 entity_type_code: type_codes::MATERIAL,
1229 layer: HypergraphLayer::AccountingNetwork,
1230 external_id: mat.material_id.clone(),
1231 label: format!("{} ({})", mat.description, mat.material_id),
1232 properties: {
1233 let mut p = HashMap::new();
1234 p.insert(
1235 "material_type".to_string(),
1236 Value::String(format!("{:?}", mat.material_type)),
1237 );
1238 p.insert(
1239 "material_group".to_string(),
1240 Value::String(format!("{:?}", mat.material_group)),
1241 );
1242 let cost: f64 = mat.standard_cost.to_string().parse().unwrap_or(0.0);
1243 p.insert("standard_cost".to_string(), serde_json::json!(cost));
1244 p
1245 },
1246 features: vec![mat
1247 .standard_cost
1248 .to_string()
1249 .parse::<f64>()
1250 .unwrap_or(0.0)
1251 .ln_1p()],
1252 is_anomaly: false,
1253 anomaly_type: None,
1254 is_aggregate: false,
1255 aggregate_count: 0,
1256 });
1257 }
1258 }
1259
1260 pub fn add_fixed_assets(&mut self, assets: &[FixedAsset]) {
1262 for asset in assets {
1263 let node_id = format!("fa_{}", asset.asset_id);
1264 self.try_add_node(HypergraphNode {
1265 id: node_id,
1266 entity_type: "fixed_asset".to_string(),
1267 entity_type_code: type_codes::FIXED_ASSET,
1268 layer: HypergraphLayer::AccountingNetwork,
1269 external_id: asset.asset_id.clone(),
1270 label: format!("{} ({})", asset.description, asset.asset_id),
1271 properties: {
1272 let mut p = HashMap::new();
1273 p.insert(
1274 "asset_class".to_string(),
1275 Value::String(format!("{:?}", asset.asset_class)),
1276 );
1277 p.insert(
1278 "company_code".to_string(),
1279 Value::String(asset.company_code.clone()),
1280 );
1281 if let Some(ref cc) = asset.cost_center {
1282 p.insert("cost_center".to_string(), Value::String(cc.clone()));
1283 }
1284 let cost: f64 = asset.acquisition_cost.to_string().parse().unwrap_or(0.0);
1285 p.insert("acquisition_cost".to_string(), serde_json::json!(cost));
1286 p
1287 },
1288 features: vec![asset
1289 .acquisition_cost
1290 .to_string()
1291 .parse::<f64>()
1292 .unwrap_or(0.0)
1293 .ln_1p()],
1294 is_anomaly: false,
1295 anomaly_type: None,
1296 is_aggregate: false,
1297 aggregate_count: 0,
1298 });
1299 }
1300 }
1301
1302 pub fn add_accounts(&mut self, coa: &ChartOfAccounts) {
1304 if !self.config.include_accounts {
1305 return;
1306 }
1307
1308 for account in &coa.accounts {
1309 let node_id = format!("acct_{}", account.account_number);
1310 if self.try_add_node(HypergraphNode {
1311 id: node_id.clone(),
1312 entity_type: "account".to_string(),
1313 entity_type_code: type_codes::ACCOUNT,
1314 layer: HypergraphLayer::AccountingNetwork,
1315 external_id: account.account_number.clone(),
1316 label: account.short_description.clone(),
1317 properties: {
1318 let mut p = HashMap::new();
1319 p.insert(
1320 "account_type".to_string(),
1321 Value::String(format!("{:?}", account.account_type)),
1322 );
1323 p.insert(
1324 "is_control_account".to_string(),
1325 Value::Bool(account.is_control_account),
1326 );
1327 p.insert("is_postable".to_string(), Value::Bool(account.is_postable));
1328 p
1329 },
1330 features: vec![
1331 account_type_feature(&account.account_type),
1332 if account.is_control_account { 1.0 } else { 0.0 },
1333 if account.normal_debit_balance {
1334 1.0
1335 } else {
1336 0.0
1337 },
1338 ],
1339 is_anomaly: false,
1340 anomaly_type: None,
1341 is_aggregate: false,
1342 aggregate_count: 0,
1343 }) {
1344 self.account_node_ids
1345 .insert(account.account_number.clone(), node_id);
1346 }
1347 }
1348 }
1349
1350 pub fn add_journal_entries_as_hyperedges(&mut self, entries: &[JournalEntry]) {
1354 if !self.config.je_as_hyperedges {
1355 return;
1356 }
1357
1358 for entry in entries {
1359 let mut participants = Vec::new();
1360
1361 for line in &entry.lines {
1362 let account_id = format!("acct_{}", line.gl_account);
1363
1364 if !self.node_index.contains_key(&account_id) {
1366 self.try_add_node(HypergraphNode {
1367 id: account_id.clone(),
1368 entity_type: "account".to_string(),
1369 entity_type_code: type_codes::ACCOUNT,
1370 layer: HypergraphLayer::AccountingNetwork,
1371 external_id: line.gl_account.clone(),
1372 label: line
1373 .account_description
1374 .clone()
1375 .unwrap_or_else(|| line.gl_account.clone()),
1376 properties: HashMap::new(),
1377 features: vec![],
1378 is_anomaly: false,
1379 anomaly_type: None,
1380 is_aggregate: false,
1381 aggregate_count: 0,
1382 });
1383 self.account_node_ids
1384 .insert(line.gl_account.clone(), account_id.clone());
1385 }
1386
1387 let amount: f64 = if !line.debit_amount.is_zero() {
1388 line.debit_amount.to_string().parse().unwrap_or(0.0)
1389 } else {
1390 line.credit_amount.to_string().parse().unwrap_or(0.0)
1391 };
1392
1393 let role = if !line.debit_amount.is_zero() {
1394 "debit"
1395 } else {
1396 "credit"
1397 };
1398
1399 participants.push(HyperedgeParticipant {
1400 node_id: account_id,
1401 role: role.to_string(),
1402 weight: Some(amount),
1403 });
1404 }
1405
1406 if participants.is_empty() {
1407 continue;
1408 }
1409
1410 let doc_id = entry.header.document_id.to_string();
1411 let subtype = entry
1412 .header
1413 .business_process
1414 .as_ref()
1415 .map(|bp| format!("{bp:?}"))
1416 .unwrap_or_else(|| "General".to_string());
1417
1418 self.hyperedges.push(Hyperedge {
1419 id: format!("je_{doc_id}"),
1420 hyperedge_type: "JournalEntry".to_string(),
1421 subtype,
1422 participants,
1423 layer: HypergraphLayer::AccountingNetwork,
1424 properties: {
1425 let mut p = HashMap::new();
1426 p.insert("document_id".to_string(), Value::String(doc_id));
1427 p.insert(
1428 "company_code".to_string(),
1429 Value::String(entry.header.company_code.clone()),
1430 );
1431 p.insert(
1432 "document_type".to_string(),
1433 Value::String(entry.header.document_type.clone()),
1434 );
1435 p.insert(
1436 "created_by".to_string(),
1437 Value::String(entry.header.created_by.clone()),
1438 );
1439 p
1440 },
1441 timestamp: Some(entry.header.posting_date),
1442 is_anomaly: entry.header.is_anomaly || entry.header.is_fraud,
1443 anomaly_type: entry
1444 .header
1445 .anomaly_type
1446 .clone()
1447 .or_else(|| entry.header.fraud_type.as_ref().map(|ft| format!("{ft:?}"))),
1448 features: compute_je_features(entry),
1449 });
1450 }
1451 }
1452
1453 pub fn add_journal_entry_nodes(&mut self, entries: &[JournalEntry]) {
1459 for entry in entries {
1460 let node_id = format!("je_{}", entry.header.document_id);
1461 let total_amount: f64 = entry
1462 .lines
1463 .iter()
1464 .map(|l| l.debit_amount.to_string().parse::<f64>().unwrap_or(0.0))
1465 .sum();
1466
1467 let is_anomaly = entry.header.is_anomaly || entry.header.is_fraud;
1468 let anomaly_type = entry
1469 .header
1470 .anomaly_type
1471 .clone()
1472 .or_else(|| entry.header.fraud_type.as_ref().map(|ft| format!("{ft:?}")));
1473
1474 self.try_add_node(HypergraphNode {
1475 id: node_id,
1476 entity_type: "journal_entry".to_string(),
1477 entity_type_code: type_codes::JOURNAL_ENTRY,
1478 layer: HypergraphLayer::AccountingNetwork,
1479 external_id: entry.header.document_id.to_string(),
1480 label: format!("JE-{}", entry.header.document_id),
1481 properties: {
1482 let mut p = HashMap::new();
1483 p.insert(
1484 "amount".into(),
1485 Value::Number(
1486 serde_json::Number::from_f64(total_amount)
1487 .unwrap_or_else(|| serde_json::Number::from(0)),
1488 ),
1489 );
1490 p.insert(
1491 "date".into(),
1492 Value::String(entry.header.posting_date.to_string()),
1493 );
1494 p.insert(
1495 "company_code".into(),
1496 Value::String(entry.header.company_code.clone()),
1497 );
1498 p.insert(
1499 "line_count".into(),
1500 Value::Number((entry.lines.len() as u64).into()),
1501 );
1502 p.insert("is_anomaly".into(), Value::Bool(is_anomaly));
1503 if let Some(ref at) = anomaly_type {
1504 p.insert("anomaly_type".into(), Value::String(at.clone()));
1505 }
1506 p
1507 },
1508 features: vec![total_amount / 100_000.0],
1509 is_anomaly,
1510 anomaly_type,
1511 is_aggregate: false,
1512 aggregate_count: 0,
1513 });
1514 }
1515 }
1516
1517 pub fn add_p2p_documents(
1521 &mut self,
1522 purchase_orders: &[datasynth_core::models::documents::PurchaseOrder],
1523 goods_receipts: &[datasynth_core::models::documents::GoodsReceipt],
1524 vendor_invoices: &[datasynth_core::models::documents::VendorInvoice],
1525 payments: &[datasynth_core::models::documents::Payment],
1526 ) {
1527 if !self.config.include_p2p {
1528 return;
1529 }
1530
1531 let mut vendor_doc_counts: HashMap<String, usize> = HashMap::new();
1533 for po in purchase_orders {
1534 *vendor_doc_counts.entry(po.vendor_id.clone()).or_insert(0) += 1;
1535 }
1536
1537 let threshold = self.config.docs_per_counterparty_threshold;
1538 let should_aggregate = matches!(
1539 self.config.aggregation_strategy,
1540 AggregationStrategy::PoolByCounterparty
1541 );
1542
1543 let vendors_needing_pools: Vec<String> = if should_aggregate {
1545 vendor_doc_counts
1546 .iter()
1547 .filter(|(_, count)| **count > threshold)
1548 .map(|(vid, _)| vid.clone())
1549 .collect()
1550 } else {
1551 Vec::new()
1552 };
1553
1554 for vendor_id in &vendors_needing_pools {
1556 let count = vendor_doc_counts[vendor_id];
1557 let pool_id = format!("pool_p2p_{vendor_id}");
1558 if self.try_add_node(HypergraphNode {
1559 id: pool_id.clone(),
1560 entity_type: "p2p_pool".to_string(),
1561 entity_type_code: type_codes::POOL_NODE,
1562 layer: HypergraphLayer::ProcessEvents,
1563 external_id: format!("pool_p2p_{vendor_id}"),
1564 label: format!("P2P Pool ({vendor_id}): {count} docs"),
1565 properties: {
1566 let mut p = HashMap::new();
1567 p.insert("vendor_id".to_string(), Value::String(vendor_id.clone()));
1568 p.insert("document_count".to_string(), Value::Number(count.into()));
1569 p
1570 },
1571 features: vec![count as f64],
1572 is_anomaly: false,
1573 anomaly_type: None,
1574 is_aggregate: true,
1575 aggregate_count: count,
1576 }) {
1577 self.doc_counterparty_links.push((
1578 pool_id,
1579 "vendor".to_string(),
1580 vendor_id.clone(),
1581 ));
1582 }
1583 self.aggregate_count += 1;
1584 }
1585
1586 for po in purchase_orders {
1588 if should_aggregate && vendors_needing_pools.contains(&po.vendor_id) {
1589 continue; }
1591
1592 let doc_id = &po.header.document_id;
1593 let node_id = format!("po_{doc_id}");
1594 if self.try_add_node(HypergraphNode {
1595 id: node_id.clone(),
1596 entity_type: "purchase_order".to_string(),
1597 entity_type_code: type_codes::PURCHASE_ORDER,
1598 layer: HypergraphLayer::ProcessEvents,
1599 external_id: doc_id.clone(),
1600 label: format!("PO {doc_id}"),
1601 properties: {
1602 let mut p = HashMap::new();
1603 p.insert("vendor_id".to_string(), Value::String(po.vendor_id.clone()));
1604 p.insert(
1605 "company_code".to_string(),
1606 Value::String(po.header.company_code.clone()),
1607 );
1608 p
1609 },
1610 features: vec![po
1611 .total_net_amount
1612 .to_string()
1613 .parse::<f64>()
1614 .unwrap_or(0.0)
1615 .ln_1p()],
1616 is_anomaly: false,
1617 anomaly_type: None,
1618 is_aggregate: false,
1619 aggregate_count: 0,
1620 }) {
1621 self.doc_counterparty_links.push((
1622 node_id,
1623 "vendor".to_string(),
1624 po.vendor_id.clone(),
1625 ));
1626 }
1627 }
1628
1629 for gr in goods_receipts {
1631 let vendor_id = gr.vendor_id.as_deref().unwrap_or("UNKNOWN");
1632 if should_aggregate && vendors_needing_pools.contains(&vendor_id.to_string()) {
1633 continue;
1634 }
1635 let doc_id = &gr.header.document_id;
1636 let node_id = format!("gr_{doc_id}");
1637 self.try_add_node(HypergraphNode {
1638 id: node_id,
1639 entity_type: "goods_receipt".to_string(),
1640 entity_type_code: type_codes::GOODS_RECEIPT,
1641 layer: HypergraphLayer::ProcessEvents,
1642 external_id: doc_id.clone(),
1643 label: format!("GR {doc_id}"),
1644 properties: {
1645 let mut p = HashMap::new();
1646 p.insert(
1647 "vendor_id".to_string(),
1648 Value::String(vendor_id.to_string()),
1649 );
1650 p
1651 },
1652 features: vec![gr
1653 .total_value
1654 .to_string()
1655 .parse::<f64>()
1656 .unwrap_or(0.0)
1657 .ln_1p()],
1658 is_anomaly: false,
1659 anomaly_type: None,
1660 is_aggregate: false,
1661 aggregate_count: 0,
1662 });
1663 }
1664
1665 for inv in vendor_invoices {
1667 if should_aggregate && vendors_needing_pools.contains(&inv.vendor_id) {
1668 continue;
1669 }
1670 let doc_id = &inv.header.document_id;
1671 let node_id = format!("vinv_{doc_id}");
1672 self.try_add_node(HypergraphNode {
1673 id: node_id,
1674 entity_type: "vendor_invoice".to_string(),
1675 entity_type_code: type_codes::VENDOR_INVOICE,
1676 layer: HypergraphLayer::ProcessEvents,
1677 external_id: doc_id.clone(),
1678 label: format!("VI {doc_id}"),
1679 properties: {
1680 let mut p = HashMap::new();
1681 p.insert(
1682 "vendor_id".to_string(),
1683 Value::String(inv.vendor_id.clone()),
1684 );
1685 p
1686 },
1687 features: vec![inv
1688 .payable_amount
1689 .to_string()
1690 .parse::<f64>()
1691 .unwrap_or(0.0)
1692 .ln_1p()],
1693 is_anomaly: false,
1694 anomaly_type: None,
1695 is_aggregate: false,
1696 aggregate_count: 0,
1697 });
1698 }
1699
1700 for pmt in payments {
1702 let doc_id = &pmt.header.document_id;
1703 let node_id = format!("pmt_{doc_id}");
1704 self.try_add_node(HypergraphNode {
1705 id: node_id,
1706 entity_type: "payment".to_string(),
1707 entity_type_code: type_codes::PAYMENT,
1708 layer: HypergraphLayer::ProcessEvents,
1709 external_id: doc_id.clone(),
1710 label: format!("PMT {doc_id}"),
1711 properties: HashMap::new(),
1712 features: vec![pmt.amount.to_string().parse::<f64>().unwrap_or(0.0).ln_1p()],
1713 is_anomaly: false,
1714 anomaly_type: None,
1715 is_aggregate: false,
1716 aggregate_count: 0,
1717 });
1718 }
1719 }
1720
1721 pub fn add_o2c_documents(
1723 &mut self,
1724 sales_orders: &[datasynth_core::models::documents::SalesOrder],
1725 deliveries: &[datasynth_core::models::documents::Delivery],
1726 customer_invoices: &[datasynth_core::models::documents::CustomerInvoice],
1727 ) {
1728 if !self.config.include_o2c {
1729 return;
1730 }
1731
1732 let mut customer_doc_counts: HashMap<String, usize> = HashMap::new();
1734 for so in sales_orders {
1735 *customer_doc_counts
1736 .entry(so.customer_id.clone())
1737 .or_insert(0) += 1;
1738 }
1739
1740 let threshold = self.config.docs_per_counterparty_threshold;
1741 let should_aggregate = matches!(
1742 self.config.aggregation_strategy,
1743 AggregationStrategy::PoolByCounterparty
1744 );
1745
1746 let customers_needing_pools: Vec<String> = if should_aggregate {
1747 customer_doc_counts
1748 .iter()
1749 .filter(|(_, count)| **count > threshold)
1750 .map(|(cid, _)| cid.clone())
1751 .collect()
1752 } else {
1753 Vec::new()
1754 };
1755
1756 for customer_id in &customers_needing_pools {
1758 let count = customer_doc_counts[customer_id];
1759 let pool_id = format!("pool_o2c_{customer_id}");
1760 if self.try_add_node(HypergraphNode {
1761 id: pool_id.clone(),
1762 entity_type: "o2c_pool".to_string(),
1763 entity_type_code: type_codes::POOL_NODE,
1764 layer: HypergraphLayer::ProcessEvents,
1765 external_id: format!("pool_o2c_{customer_id}"),
1766 label: format!("O2C Pool ({customer_id}): {count} docs"),
1767 properties: {
1768 let mut p = HashMap::new();
1769 p.insert(
1770 "customer_id".to_string(),
1771 Value::String(customer_id.clone()),
1772 );
1773 p.insert("document_count".to_string(), Value::Number(count.into()));
1774 p
1775 },
1776 features: vec![count as f64],
1777 is_anomaly: false,
1778 anomaly_type: None,
1779 is_aggregate: true,
1780 aggregate_count: count,
1781 }) {
1782 self.doc_counterparty_links.push((
1783 pool_id,
1784 "customer".to_string(),
1785 customer_id.clone(),
1786 ));
1787 }
1788 self.aggregate_count += 1;
1789 }
1790
1791 for so in sales_orders {
1792 if should_aggregate && customers_needing_pools.contains(&so.customer_id) {
1793 continue;
1794 }
1795 let doc_id = &so.header.document_id;
1796 let node_id = format!("so_{doc_id}");
1797 if self.try_add_node(HypergraphNode {
1798 id: node_id.clone(),
1799 entity_type: "sales_order".to_string(),
1800 entity_type_code: type_codes::SALES_ORDER,
1801 layer: HypergraphLayer::ProcessEvents,
1802 external_id: doc_id.clone(),
1803 label: format!("SO {doc_id}"),
1804 properties: {
1805 let mut p = HashMap::new();
1806 p.insert(
1807 "customer_id".to_string(),
1808 Value::String(so.customer_id.clone()),
1809 );
1810 p
1811 },
1812 features: vec![so
1813 .total_net_amount
1814 .to_string()
1815 .parse::<f64>()
1816 .unwrap_or(0.0)
1817 .ln_1p()],
1818 is_anomaly: false,
1819 anomaly_type: None,
1820 is_aggregate: false,
1821 aggregate_count: 0,
1822 }) {
1823 self.doc_counterparty_links.push((
1824 node_id,
1825 "customer".to_string(),
1826 so.customer_id.clone(),
1827 ));
1828 }
1829 }
1830
1831 for del in deliveries {
1832 if should_aggregate && customers_needing_pools.contains(&del.customer_id) {
1833 continue;
1834 }
1835 let doc_id = &del.header.document_id;
1836 let node_id = format!("del_{doc_id}");
1837 self.try_add_node(HypergraphNode {
1838 id: node_id,
1839 entity_type: "delivery".to_string(),
1840 entity_type_code: type_codes::DELIVERY,
1841 layer: HypergraphLayer::ProcessEvents,
1842 external_id: doc_id.clone(),
1843 label: format!("DEL {doc_id}"),
1844 properties: HashMap::new(),
1845 features: vec![],
1846 is_anomaly: false,
1847 anomaly_type: None,
1848 is_aggregate: false,
1849 aggregate_count: 0,
1850 });
1851 }
1852
1853 for inv in customer_invoices {
1854 if should_aggregate && customers_needing_pools.contains(&inv.customer_id) {
1855 continue;
1856 }
1857 let doc_id = &inv.header.document_id;
1858 let node_id = format!("cinv_{doc_id}");
1859 self.try_add_node(HypergraphNode {
1860 id: node_id,
1861 entity_type: "customer_invoice".to_string(),
1862 entity_type_code: type_codes::CUSTOMER_INVOICE,
1863 layer: HypergraphLayer::ProcessEvents,
1864 external_id: doc_id.clone(),
1865 label: format!("CI {doc_id}"),
1866 properties: HashMap::new(),
1867 features: vec![inv
1868 .total_gross_amount
1869 .to_string()
1870 .parse::<f64>()
1871 .unwrap_or(0.0)
1872 .ln_1p()],
1873 is_anomaly: false,
1874 anomaly_type: None,
1875 is_aggregate: false,
1876 aggregate_count: 0,
1877 });
1878 }
1879 }
1880
1881 pub fn add_s2c_documents(
1883 &mut self,
1884 projects: &[SourcingProject],
1885 qualifications: &[SupplierQualification],
1886 rfx_events: &[RfxEvent],
1887 bids: &[SupplierBid],
1888 evaluations: &[BidEvaluation],
1889 contracts: &[ProcurementContract],
1890 ) {
1891 if !self.config.include_s2c {
1892 return;
1893 }
1894 for p in projects {
1895 let node_id = format!("s2c_proj_{}", p.project_id);
1896 self.try_add_node(HypergraphNode {
1897 id: node_id,
1898 entity_type: "sourcing_project".into(),
1899 entity_type_code: type_codes::SOURCING_PROJECT,
1900 layer: HypergraphLayer::ProcessEvents,
1901 external_id: p.project_id.clone(),
1902 label: format!("SPRJ {}", p.project_id),
1903 properties: HashMap::new(),
1904 features: vec![p
1905 .estimated_annual_spend
1906 .to_string()
1907 .parse::<f64>()
1908 .unwrap_or(0.0)
1909 .ln_1p()],
1910 is_anomaly: false,
1911 anomaly_type: None,
1912 is_aggregate: false,
1913 aggregate_count: 0,
1914 });
1915 }
1916 for q in qualifications {
1917 let node_id = format!("s2c_qual_{}", q.qualification_id);
1918 self.try_add_node(HypergraphNode {
1919 id: node_id,
1920 entity_type: "supplier_qualification".into(),
1921 entity_type_code: type_codes::SUPPLIER_QUALIFICATION,
1922 layer: HypergraphLayer::ProcessEvents,
1923 external_id: q.qualification_id.clone(),
1924 label: format!("SQUAL {}", q.qualification_id),
1925 properties: HashMap::new(),
1926 features: vec![],
1927 is_anomaly: false,
1928 anomaly_type: None,
1929 is_aggregate: false,
1930 aggregate_count: 0,
1931 });
1932 }
1933 for r in rfx_events {
1934 let node_id = format!("s2c_rfx_{}", r.rfx_id);
1935 self.try_add_node(HypergraphNode {
1936 id: node_id,
1937 entity_type: "rfx_event".into(),
1938 entity_type_code: type_codes::RFX_EVENT,
1939 layer: HypergraphLayer::ProcessEvents,
1940 external_id: r.rfx_id.clone(),
1941 label: format!("RFX {}", r.rfx_id),
1942 properties: HashMap::new(),
1943 features: vec![],
1944 is_anomaly: false,
1945 anomaly_type: None,
1946 is_aggregate: false,
1947 aggregate_count: 0,
1948 });
1949 }
1950 for b in bids {
1951 let node_id = format!("s2c_bid_{}", b.bid_id);
1952 self.try_add_node(HypergraphNode {
1953 id: node_id,
1954 entity_type: "supplier_bid".into(),
1955 entity_type_code: type_codes::SUPPLIER_BID,
1956 layer: HypergraphLayer::ProcessEvents,
1957 external_id: b.bid_id.clone(),
1958 label: format!("BID {}", b.bid_id),
1959 properties: HashMap::new(),
1960 features: vec![b
1961 .total_amount
1962 .to_string()
1963 .parse::<f64>()
1964 .unwrap_or(0.0)
1965 .ln_1p()],
1966 is_anomaly: false,
1967 anomaly_type: None,
1968 is_aggregate: false,
1969 aggregate_count: 0,
1970 });
1971 }
1972 for e in evaluations {
1973 let node_id = format!("s2c_eval_{}", e.evaluation_id);
1974 self.try_add_node(HypergraphNode {
1975 id: node_id,
1976 entity_type: "bid_evaluation".into(),
1977 entity_type_code: type_codes::BID_EVALUATION,
1978 layer: HypergraphLayer::ProcessEvents,
1979 external_id: e.evaluation_id.clone(),
1980 label: format!("BEVAL {}", e.evaluation_id),
1981 properties: HashMap::new(),
1982 features: vec![],
1983 is_anomaly: false,
1984 anomaly_type: None,
1985 is_aggregate: false,
1986 aggregate_count: 0,
1987 });
1988 }
1989 for c in contracts {
1990 let node_id = format!("s2c_ctr_{}", c.contract_id);
1991 self.try_add_node(HypergraphNode {
1992 id: node_id,
1993 entity_type: "procurement_contract".into(),
1994 entity_type_code: type_codes::PROCUREMENT_CONTRACT,
1995 layer: HypergraphLayer::ProcessEvents,
1996 external_id: c.contract_id.clone(),
1997 label: format!("CTR {}", c.contract_id),
1998 properties: HashMap::new(),
1999 features: vec![c
2000 .total_value
2001 .to_string()
2002 .parse::<f64>()
2003 .unwrap_or(0.0)
2004 .ln_1p()],
2005 is_anomaly: false,
2006 anomaly_type: None,
2007 is_aggregate: false,
2008 aggregate_count: 0,
2009 });
2010 self.doc_counterparty_links.push((
2012 format!("s2c_ctr_{}", c.contract_id),
2013 "vendor".into(),
2014 c.vendor_id.clone(),
2015 ));
2016 }
2017 }
2018
2019 pub fn add_h2r_documents(
2021 &mut self,
2022 payroll_runs: &[PayrollRun],
2023 time_entries: &[TimeEntry],
2024 expense_reports: &[ExpenseReport],
2025 ) {
2026 if !self.config.include_h2r {
2027 return;
2028 }
2029 for pr in payroll_runs {
2030 let node_id = format!("h2r_pay_{}", pr.payroll_id);
2031 self.try_add_node(HypergraphNode {
2032 id: node_id,
2033 entity_type: "payroll_run".into(),
2034 entity_type_code: type_codes::PAYROLL_RUN,
2035 layer: HypergraphLayer::ProcessEvents,
2036 external_id: pr.payroll_id.clone(),
2037 label: format!("PAY {}", pr.payroll_id),
2038 properties: HashMap::new(),
2039 features: vec![pr
2040 .total_gross
2041 .to_string()
2042 .parse::<f64>()
2043 .unwrap_or(0.0)
2044 .ln_1p()],
2045 is_anomaly: false,
2046 anomaly_type: None,
2047 is_aggregate: false,
2048 aggregate_count: 0,
2049 });
2050 }
2051 for te in time_entries {
2052 let node_id = format!("h2r_time_{}", te.entry_id);
2053 self.try_add_node(HypergraphNode {
2054 id: node_id,
2055 entity_type: "time_entry".into(),
2056 entity_type_code: type_codes::TIME_ENTRY,
2057 layer: HypergraphLayer::ProcessEvents,
2058 external_id: te.entry_id.clone(),
2059 label: format!("TIME {}", te.entry_id),
2060 properties: HashMap::new(),
2061 features: vec![te.hours_regular + te.hours_overtime],
2062 is_anomaly: false,
2063 anomaly_type: None,
2064 is_aggregate: false,
2065 aggregate_count: 0,
2066 });
2067 }
2068 for er in expense_reports {
2069 let node_id = format!("h2r_exp_{}", er.report_id);
2070 self.try_add_node(HypergraphNode {
2071 id: node_id,
2072 entity_type: "expense_report".into(),
2073 entity_type_code: type_codes::EXPENSE_REPORT,
2074 layer: HypergraphLayer::ProcessEvents,
2075 external_id: er.report_id.clone(),
2076 label: format!("EXP {}", er.report_id),
2077 properties: HashMap::new(),
2078 features: vec![er
2079 .total_amount
2080 .to_string()
2081 .parse::<f64>()
2082 .unwrap_or(0.0)
2083 .ln_1p()],
2084 is_anomaly: false,
2085 anomaly_type: None,
2086 is_aggregate: false,
2087 aggregate_count: 0,
2088 });
2089 }
2090 }
2091
2092 pub fn add_mfg_documents(
2094 &mut self,
2095 production_orders: &[ProductionOrder],
2096 quality_inspections: &[QualityInspection],
2097 cycle_counts: &[CycleCount],
2098 ) {
2099 if !self.config.include_mfg {
2100 return;
2101 }
2102 for po in production_orders {
2103 let node_id = format!("mfg_po_{}", po.order_id);
2104 self.try_add_node(HypergraphNode {
2105 id: node_id,
2106 entity_type: "production_order".into(),
2107 entity_type_code: type_codes::PRODUCTION_ORDER,
2108 layer: HypergraphLayer::ProcessEvents,
2109 external_id: po.order_id.clone(),
2110 label: format!("PROD {}", po.order_id),
2111 properties: HashMap::new(),
2112 features: vec![po
2113 .planned_quantity
2114 .to_string()
2115 .parse::<f64>()
2116 .unwrap_or(0.0)
2117 .ln_1p()],
2118 is_anomaly: false,
2119 anomaly_type: None,
2120 is_aggregate: false,
2121 aggregate_count: 0,
2122 });
2123 }
2124 for qi in quality_inspections {
2125 let node_id = format!("mfg_qi_{}", qi.inspection_id);
2126 self.try_add_node(HypergraphNode {
2127 id: node_id,
2128 entity_type: "quality_inspection".into(),
2129 entity_type_code: type_codes::QUALITY_INSPECTION,
2130 layer: HypergraphLayer::ProcessEvents,
2131 external_id: qi.inspection_id.clone(),
2132 label: format!("QI {}", qi.inspection_id),
2133 properties: HashMap::new(),
2134 features: vec![qi.defect_rate],
2135 is_anomaly: false,
2136 anomaly_type: None,
2137 is_aggregate: false,
2138 aggregate_count: 0,
2139 });
2140 }
2141 for cc in cycle_counts {
2142 let node_id = format!("mfg_cc_{}", cc.count_id);
2143 self.try_add_node(HypergraphNode {
2144 id: node_id,
2145 entity_type: "cycle_count".into(),
2146 entity_type_code: type_codes::CYCLE_COUNT,
2147 layer: HypergraphLayer::ProcessEvents,
2148 external_id: cc.count_id.clone(),
2149 label: format!("CC {}", cc.count_id),
2150 properties: HashMap::new(),
2151 features: vec![cc.variance_rate],
2152 is_anomaly: false,
2153 anomaly_type: None,
2154 is_aggregate: false,
2155 aggregate_count: 0,
2156 });
2157 }
2158 }
2159
2160 pub fn add_bank_documents(
2162 &mut self,
2163 customers: &[BankingCustomer],
2164 accounts: &[BankAccount],
2165 transactions: &[BankTransaction],
2166 ) {
2167 if !self.config.include_bank {
2168 return;
2169 }
2170 for cust in customers {
2171 let cid = cust.customer_id.to_string();
2172 let node_id = format!("bank_cust_{cid}");
2173 self.try_add_node(HypergraphNode {
2174 id: node_id,
2175 entity_type: "banking_customer".into(),
2176 entity_type_code: type_codes::BANKING_CUSTOMER,
2177 layer: HypergraphLayer::ProcessEvents,
2178 external_id: cid,
2179 label: format!("BCUST {}", cust.customer_id),
2180 properties: {
2181 let mut p = HashMap::new();
2182 p.insert(
2183 "customer_type".into(),
2184 Value::String(format!("{:?}", cust.customer_type)),
2185 );
2186 p.insert("name".into(), Value::String(cust.name.legal_name.clone()));
2187 p.insert(
2188 "residence_country".into(),
2189 Value::String(cust.residence_country.clone()),
2190 );
2191 p.insert(
2192 "risk_tier".into(),
2193 Value::String(format!("{:?}", cust.risk_tier)),
2194 );
2195 p.insert("is_pep".into(), Value::Bool(cust.is_pep));
2196 p
2197 },
2198 features: vec![],
2199 is_anomaly: cust.is_mule,
2200 anomaly_type: if cust.is_mule {
2201 Some("mule_account".into())
2202 } else {
2203 None
2204 },
2205 is_aggregate: false,
2206 aggregate_count: 0,
2207 });
2208 }
2209 for acct in accounts {
2210 let aid = acct.account_id.to_string();
2211 let node_id = format!("bank_acct_{aid}");
2212 self.try_add_node(HypergraphNode {
2213 id: node_id,
2214 entity_type: "bank_account".into(),
2215 entity_type_code: type_codes::BANK_ACCOUNT,
2216 layer: HypergraphLayer::ProcessEvents,
2217 external_id: aid,
2218 label: format!("BACCT {}", acct.account_number),
2219 properties: {
2220 let mut p = HashMap::new();
2221 p.insert(
2222 "account_type".into(),
2223 Value::String(format!("{:?}", acct.account_type)),
2224 );
2225 p.insert("status".into(), Value::String(format!("{:?}", acct.status)));
2226 p.insert("currency".into(), Value::String(acct.currency.clone()));
2227 let balance: f64 = acct.current_balance.to_string().parse().unwrap_or(0.0);
2228 p.insert("balance".into(), serde_json::json!(balance));
2229 p.insert(
2230 "account_number".into(),
2231 Value::String(acct.account_number.clone()),
2232 );
2233 p
2234 },
2235 features: vec![acct
2236 .current_balance
2237 .to_string()
2238 .parse::<f64>()
2239 .unwrap_or(0.0)
2240 .ln_1p()],
2241 is_anomaly: acct.is_mule_account,
2242 anomaly_type: if acct.is_mule_account {
2243 Some("mule_account".into())
2244 } else {
2245 None
2246 },
2247 is_aggregate: false,
2248 aggregate_count: 0,
2249 });
2250 }
2251 for txn in transactions {
2252 let tid = txn.transaction_id.to_string();
2253 let node_id = format!("bank_txn_{tid}");
2254 self.try_add_node(HypergraphNode {
2255 id: node_id,
2256 entity_type: "bank_transaction".into(),
2257 entity_type_code: type_codes::BANK_TRANSACTION,
2258 layer: HypergraphLayer::ProcessEvents,
2259 external_id: tid,
2260 label: format!("BTXN {}", txn.reference),
2261 properties: {
2262 let mut p = HashMap::new();
2263 let amount: f64 = txn.amount.to_string().parse().unwrap_or(0.0);
2264 p.insert("amount".into(), serde_json::json!(amount));
2265 p.insert("currency".into(), Value::String(txn.currency.clone()));
2266 p.insert("reference".into(), Value::String(txn.reference.clone()));
2267 p.insert(
2268 "direction".into(),
2269 Value::String(format!("{:?}", txn.direction)),
2270 );
2271 p.insert(
2272 "channel".into(),
2273 Value::String(format!("{:?}", txn.channel)),
2274 );
2275 p.insert(
2276 "category".into(),
2277 Value::String(format!("{:?}", txn.category)),
2278 );
2279 p.insert(
2280 "transaction_type".into(),
2281 Value::String(txn.transaction_type.clone()),
2282 );
2283 p.insert("status".into(), Value::String(format!("{:?}", txn.status)));
2284 if txn.is_suspicious {
2285 p.insert("is_suspicious".into(), Value::Bool(true));
2286 if let Some(ref reason) = txn.suspicion_reason {
2287 p.insert(
2288 "suspicion_reason".into(),
2289 Value::String(format!("{reason:?}")),
2290 );
2291 }
2292 if let Some(ref stage) = txn.laundering_stage {
2293 p.insert(
2294 "laundering_stage".into(),
2295 Value::String(format!("{stage:?}")),
2296 );
2297 }
2298 }
2299 p
2300 },
2301 features: vec![txn
2302 .amount
2303 .to_string()
2304 .parse::<f64>()
2305 .unwrap_or(0.0)
2306 .abs()
2307 .ln_1p()],
2308 is_anomaly: txn.is_suspicious,
2309 anomaly_type: txn.suspicion_reason.as_ref().map(|r| format!("{r:?}")),
2310 is_aggregate: false,
2311 aggregate_count: 0,
2312 });
2313 }
2314 }
2315
2316 #[allow(clippy::too_many_arguments)]
2318 pub fn add_audit_documents(
2319 &mut self,
2320 engagements: &[AuditEngagement],
2321 workpapers: &[Workpaper],
2322 findings: &[AuditFinding],
2323 evidence: &[AuditEvidence],
2324 risks: &[RiskAssessment],
2325 judgments: &[ProfessionalJudgment],
2326 ) {
2327 if !self.config.include_audit {
2328 return;
2329 }
2330 for eng in engagements {
2331 let eid = eng.engagement_id.to_string();
2332 let node_id = format!("audit_eng_{eid}");
2333 self.try_add_node(HypergraphNode {
2334 id: node_id,
2335 entity_type: "audit_engagement".into(),
2336 entity_type_code: type_codes::AUDIT_ENGAGEMENT,
2337 layer: HypergraphLayer::ProcessEvents,
2338 external_id: eid,
2339 label: format!("AENG {}", eng.engagement_ref),
2340 properties: {
2341 let mut p = HashMap::new();
2342 p.insert(
2343 "engagement_ref".into(),
2344 Value::String(eng.engagement_ref.clone()),
2345 );
2346 p.insert("status".into(), Value::String(format!("{:?}", eng.status)));
2347 p.insert(
2348 "engagement_type".into(),
2349 Value::String(format!("{:?}", eng.engagement_type)),
2350 );
2351 p.insert("client_name".into(), Value::String(eng.client_name.clone()));
2352 p.insert("fiscal_year".into(), serde_json::json!(eng.fiscal_year));
2353 let mat: f64 = eng.materiality.to_string().parse().unwrap_or(0.0);
2354 p.insert("materiality".into(), serde_json::json!(mat));
2355 p.insert(
2356 "fieldwork_start".into(),
2357 Value::String(eng.fieldwork_start.to_string()),
2358 );
2359 p.insert(
2360 "fieldwork_end".into(),
2361 Value::String(eng.fieldwork_end.to_string()),
2362 );
2363 p
2364 },
2365 features: vec![eng
2366 .materiality
2367 .to_string()
2368 .parse::<f64>()
2369 .unwrap_or(0.0)
2370 .ln_1p()],
2371 is_anomaly: false,
2372 anomaly_type: None,
2373 is_aggregate: false,
2374 aggregate_count: 0,
2375 });
2376 }
2377 for wp in workpapers {
2378 let wid = wp.workpaper_id.to_string();
2379 let node_id = format!("audit_wp_{wid}");
2380 self.try_add_node(HypergraphNode {
2381 id: node_id,
2382 entity_type: "workpaper".into(),
2383 entity_type_code: type_codes::WORKPAPER,
2384 layer: HypergraphLayer::ProcessEvents,
2385 external_id: wid,
2386 label: format!("WP {}", wp.workpaper_ref),
2387 properties: {
2388 let mut p = HashMap::new();
2389 p.insert(
2390 "workpaper_ref".into(),
2391 Value::String(wp.workpaper_ref.clone()),
2392 );
2393 p.insert("title".into(), Value::String(wp.title.clone()));
2394 p.insert("status".into(), Value::String(format!("{:?}", wp.status)));
2395 p.insert("section".into(), Value::String(format!("{:?}", wp.section)));
2396 p
2397 },
2398 features: vec![],
2399 is_anomaly: false,
2400 anomaly_type: None,
2401 is_aggregate: false,
2402 aggregate_count: 0,
2403 });
2404 }
2405 for f in findings {
2406 let fid = f.finding_id.to_string();
2407 let node_id = format!("audit_find_{fid}");
2408 self.try_add_node(HypergraphNode {
2409 id: node_id,
2410 entity_type: "audit_finding".into(),
2411 entity_type_code: type_codes::AUDIT_FINDING,
2412 layer: HypergraphLayer::ProcessEvents,
2413 external_id: fid,
2414 label: format!("AFIND {}", f.finding_ref),
2415 properties: {
2416 let mut p = HashMap::new();
2417 p.insert("finding_ref".into(), Value::String(f.finding_ref.clone()));
2418 p.insert("title".into(), Value::String(f.title.clone()));
2419 p.insert("description".into(), Value::String(f.condition.clone()));
2420 p.insert(
2421 "severity".into(),
2422 Value::String(format!("{:?}", f.severity)),
2423 );
2424 p.insert("status".into(), Value::String(format!("{:?}", f.status)));
2425 p.insert(
2426 "finding_type".into(),
2427 Value::String(format!("{:?}", f.finding_type)),
2428 );
2429 p
2430 },
2431 features: vec![f.severity.score() as f64 / 5.0],
2432 is_anomaly: false,
2433 anomaly_type: None,
2434 is_aggregate: false,
2435 aggregate_count: 0,
2436 });
2437 }
2438 for ev in evidence {
2439 let evid = ev.evidence_id.to_string();
2440 let node_id = format!("audit_ev_{evid}");
2441 self.try_add_node(HypergraphNode {
2442 id: node_id,
2443 entity_type: "audit_evidence".into(),
2444 entity_type_code: type_codes::AUDIT_EVIDENCE,
2445 layer: HypergraphLayer::ProcessEvents,
2446 external_id: evid,
2447 label: format!("AEV {}", ev.evidence_id),
2448 properties: {
2449 let mut p = HashMap::new();
2450 p.insert(
2451 "evidence_type".into(),
2452 Value::String(format!("{:?}", ev.evidence_type)),
2453 );
2454 p.insert("description".into(), Value::String(ev.description.clone()));
2455 p.insert(
2456 "source_type".into(),
2457 Value::String(format!("{:?}", ev.source_type)),
2458 );
2459 p.insert(
2460 "reliability".into(),
2461 Value::String(format!(
2462 "{:?}",
2463 ev.reliability_assessment.overall_reliability
2464 )),
2465 );
2466 p
2467 },
2468 features: vec![ev.reliability_assessment.overall_reliability.score() as f64 / 3.0],
2469 is_anomaly: false,
2470 anomaly_type: None,
2471 is_aggregate: false,
2472 aggregate_count: 0,
2473 });
2474 }
2475 for r in risks {
2476 let rid = r.risk_id.to_string();
2477 let node_id = format!("audit_risk_{rid}");
2478 self.try_add_node(HypergraphNode {
2479 id: node_id,
2480 entity_type: "risk_assessment".into(),
2481 entity_type_code: type_codes::RISK_ASSESSMENT,
2482 layer: HypergraphLayer::ProcessEvents,
2483 external_id: rid,
2484 label: format!("ARISK {}", r.risk_ref),
2485 properties: {
2486 let mut p = HashMap::new();
2487 p.insert("risk_ref".into(), Value::String(r.risk_ref.clone()));
2488 p.insert(
2489 "account_or_process".into(),
2490 Value::String(r.account_or_process.clone()),
2491 );
2492 p.insert(
2493 "response_nature".into(),
2494 Value::String(format!("{:?}", r.response_nature)),
2495 );
2496 p
2497 },
2498 features: vec![
2499 r.inherent_risk.score() as f64 / 4.0,
2500 r.control_risk.score() as f64 / 4.0,
2501 if r.is_significant_risk { 1.0 } else { 0.0 },
2502 ],
2503 is_anomaly: false,
2504 anomaly_type: None,
2505 is_aggregate: false,
2506 aggregate_count: 0,
2507 });
2508 }
2509 for j in judgments {
2510 let jid = j.judgment_id.to_string();
2511 let node_id = format!("audit_judg_{jid}");
2512 self.try_add_node(HypergraphNode {
2513 id: node_id,
2514 entity_type: "professional_judgment".into(),
2515 entity_type_code: type_codes::PROFESSIONAL_JUDGMENT,
2516 layer: HypergraphLayer::ProcessEvents,
2517 external_id: jid,
2518 label: format!("AJUDG {}", j.judgment_id),
2519 properties: {
2520 let mut p = HashMap::new();
2521 p.insert("judgment_ref".into(), Value::String(j.judgment_ref.clone()));
2522 p.insert("subject".into(), Value::String(j.subject.clone()));
2523 p.insert(
2524 "description".into(),
2525 Value::String(j.issue_description.clone()),
2526 );
2527 p.insert("conclusion".into(), Value::String(j.conclusion.clone()));
2528 p.insert(
2529 "judgment_type".into(),
2530 Value::String(format!("{:?}", j.judgment_type)),
2531 );
2532 p
2533 },
2534 features: vec![],
2535 is_anomaly: false,
2536 anomaly_type: None,
2537 is_aggregate: false,
2538 aggregate_count: 0,
2539 });
2540 }
2541 }
2542
2543 #[allow(clippy::too_many_arguments)]
2552 pub fn add_audit_procedure_entities(
2553 &mut self,
2554 confirmations: &[ExternalConfirmation],
2555 responses: &[ConfirmationResponse],
2556 steps: &[AuditProcedureStep],
2557 samples: &[AuditSample],
2558 analytical_results: &[AnalyticalProcedureResult],
2559 ia_functions: &[InternalAuditFunction],
2560 ia_reports: &[InternalAuditReport],
2561 related_parties: &[RelatedParty],
2562 rp_transactions: &[RelatedPartyTransaction],
2563 ) {
2564 if !self.config.include_audit {
2565 return;
2566 }
2567
2568 for conf in confirmations {
2570 let ext_id = conf.confirmation_id.to_string();
2571 let node_id = format!("audit_conf_{ext_id}");
2572 let added = self.try_add_node(HypergraphNode {
2573 id: node_id.clone(),
2574 entity_type: "external_confirmation".into(),
2575 entity_type_code: type_codes::EXTERNAL_CONFIRMATION,
2576 layer: HypergraphLayer::GovernanceControls,
2577 external_id: ext_id.clone(),
2578 label: format!("CONF {}", conf.confirmation_ref),
2579 properties: {
2580 let mut p = HashMap::new();
2581 p.insert(
2582 "entity_id".into(),
2583 Value::String(conf.confirmation_ref.clone()),
2584 );
2585 p.insert("process_family".into(), Value::String("AUDIT".into()));
2586 p
2587 },
2588 features: vec![],
2589 is_anomaly: false,
2590 anomaly_type: None,
2591 is_aggregate: false,
2592 aggregate_count: 0,
2593 });
2594 if added {
2595 if let Some(wp_id) = &conf.workpaper_id {
2596 self.edges.push(CrossLayerEdge {
2597 source_id: node_id.clone(),
2598 source_layer: HypergraphLayer::GovernanceControls,
2599 target_id: format!("audit_wp_{wp_id}"),
2600 target_layer: HypergraphLayer::ProcessEvents,
2601 edge_type: "CONFIRMATION_IN_WORKPAPER".into(),
2602 edge_type_code: type_codes::CONFIRMATION_IN_WORKPAPER,
2603 properties: HashMap::new(),
2604 });
2605 }
2606 if let Some(acct_id) = &conf.account_id {
2607 self.edges.push(CrossLayerEdge {
2608 source_id: node_id,
2609 source_layer: HypergraphLayer::GovernanceControls,
2610 target_id: format!("acct_{acct_id}"),
2611 target_layer: HypergraphLayer::AccountingNetwork,
2612 edge_type: "CONFIRMATION_FOR_ACCOUNT".into(),
2613 edge_type_code: type_codes::CONFIRMATION_FOR_ACCOUNT,
2614 properties: HashMap::new(),
2615 });
2616 }
2617 }
2618 }
2619
2620 for resp in responses {
2622 let ext_id = resp.response_id.to_string();
2623 let node_id = format!("audit_resp_{ext_id}");
2624 let added = self.try_add_node(HypergraphNode {
2625 id: node_id.clone(),
2626 entity_type: "confirmation_response".into(),
2627 entity_type_code: type_codes::CONFIRMATION_RESPONSE,
2628 layer: HypergraphLayer::GovernanceControls,
2629 external_id: ext_id.clone(),
2630 label: format!("RESP {}", resp.response_ref),
2631 properties: {
2632 let mut p = HashMap::new();
2633 p.insert("entity_id".into(), Value::String(resp.response_ref.clone()));
2634 p.insert("process_family".into(), Value::String("AUDIT".into()));
2635 p
2636 },
2637 features: vec![],
2638 is_anomaly: false,
2639 anomaly_type: None,
2640 is_aggregate: false,
2641 aggregate_count: 0,
2642 });
2643 if added {
2644 self.edges.push(CrossLayerEdge {
2645 source_id: node_id,
2646 source_layer: HypergraphLayer::GovernanceControls,
2647 target_id: format!("audit_conf_{}", resp.confirmation_id),
2648 target_layer: HypergraphLayer::GovernanceControls,
2649 edge_type: "CONFIRMATION_RESPONSE".into(),
2650 edge_type_code: type_codes::CONFIRMATION_RESPONSE_EDGE,
2651 properties: HashMap::new(),
2652 });
2653 }
2654 }
2655
2656 for step in steps {
2658 let ext_id = step.step_id.to_string();
2659 let node_id = format!("audit_step_{ext_id}");
2660 let added = self.try_add_node(HypergraphNode {
2661 id: node_id.clone(),
2662 entity_type: "audit_procedure_step".into(),
2663 entity_type_code: type_codes::AUDIT_PROCEDURE_STEP,
2664 layer: HypergraphLayer::GovernanceControls,
2665 external_id: ext_id.clone(),
2666 label: format!("STEP {}", step.step_ref),
2667 properties: {
2668 let mut p = HashMap::new();
2669 p.insert("entity_id".into(), Value::String(step.step_ref.clone()));
2670 p.insert("process_family".into(), Value::String("AUDIT".into()));
2671 p
2672 },
2673 features: vec![],
2674 is_anomaly: false,
2675 anomaly_type: None,
2676 is_aggregate: false,
2677 aggregate_count: 0,
2678 });
2679 if added {
2680 self.edges.push(CrossLayerEdge {
2681 source_id: node_id.clone(),
2682 source_layer: HypergraphLayer::GovernanceControls,
2683 target_id: format!("audit_wp_{}", step.workpaper_id),
2684 target_layer: HypergraphLayer::ProcessEvents,
2685 edge_type: "STEP_IN_WORKPAPER".into(),
2686 edge_type_code: type_codes::STEP_IN_WORKPAPER,
2687 properties: HashMap::new(),
2688 });
2689 if let Some(sid) = &step.sample_id {
2690 self.edges.push(CrossLayerEdge {
2691 source_id: node_id.clone(),
2692 source_layer: HypergraphLayer::GovernanceControls,
2693 target_id: format!("audit_samp_{sid}"),
2694 target_layer: HypergraphLayer::GovernanceControls,
2695 edge_type: "STEP_USES_SAMPLE".into(),
2696 edge_type_code: type_codes::STEP_USES_SAMPLE,
2697 properties: HashMap::new(),
2698 });
2699 }
2700 for eid in &step.evidence_ids {
2701 self.edges.push(CrossLayerEdge {
2702 source_id: node_id.clone(),
2703 source_layer: HypergraphLayer::GovernanceControls,
2704 target_id: format!("audit_ev_{eid}"),
2705 target_layer: HypergraphLayer::ProcessEvents,
2706 edge_type: "STEP_EVIDENCE".into(),
2707 edge_type_code: type_codes::STEP_EVIDENCE,
2708 properties: HashMap::new(),
2709 });
2710 }
2711 }
2712 }
2713
2714 for sample in samples {
2716 let ext_id = sample.sample_id.to_string();
2717 let node_id = format!("audit_samp_{ext_id}");
2718 let added = self.try_add_node(HypergraphNode {
2719 id: node_id.clone(),
2720 entity_type: "audit_sample".into(),
2721 entity_type_code: type_codes::AUDIT_SAMPLE,
2722 layer: HypergraphLayer::GovernanceControls,
2723 external_id: ext_id.clone(),
2724 label: format!("SAMP {}", sample.sample_ref),
2725 properties: {
2726 let mut p = HashMap::new();
2727 p.insert("entity_id".into(), Value::String(sample.sample_ref.clone()));
2728 p.insert("process_family".into(), Value::String("AUDIT".into()));
2729 p
2730 },
2731 features: vec![],
2732 is_anomaly: false,
2733 anomaly_type: None,
2734 is_aggregate: false,
2735 aggregate_count: 0,
2736 });
2737 if added {
2738 self.edges.push(CrossLayerEdge {
2739 source_id: node_id,
2740 source_layer: HypergraphLayer::GovernanceControls,
2741 target_id: format!("audit_wp_{}", sample.workpaper_id),
2742 target_layer: HypergraphLayer::ProcessEvents,
2743 edge_type: "SAMPLE_FROM_WORKPAPER".into(),
2744 edge_type_code: type_codes::SAMPLE_FROM_WORKPAPER,
2745 properties: HashMap::new(),
2746 });
2747 }
2748 }
2749
2750 for ap in analytical_results {
2752 let ext_id = ap.result_id.to_string();
2753 let node_id = format!("audit_ap_{ext_id}");
2754 let added = self.try_add_node(HypergraphNode {
2755 id: node_id.clone(),
2756 entity_type: "analytical_procedure_result".into(),
2757 entity_type_code: type_codes::ANALYTICAL_PROCEDURE_RESULT,
2758 layer: HypergraphLayer::GovernanceControls,
2759 external_id: ext_id.clone(),
2760 label: format!("AP {}", ap.result_ref),
2761 properties: {
2762 let mut p = HashMap::new();
2763 p.insert("entity_id".into(), Value::String(ap.result_ref.clone()));
2764 p.insert("process_family".into(), Value::String("AUDIT".into()));
2765 p
2766 },
2767 features: vec![ap.variance_percentage.abs().ln_1p()],
2768 is_anomaly: ap.requires_investigation,
2769 anomaly_type: if ap.requires_investigation {
2770 Some("analytical_variance".into())
2771 } else {
2772 None
2773 },
2774 is_aggregate: false,
2775 aggregate_count: 0,
2776 });
2777 if added {
2778 if let Some(wp_id) = &ap.workpaper_id {
2779 self.edges.push(CrossLayerEdge {
2780 source_id: node_id.clone(),
2781 source_layer: HypergraphLayer::GovernanceControls,
2782 target_id: format!("audit_wp_{wp_id}"),
2783 target_layer: HypergraphLayer::ProcessEvents,
2784 edge_type: "AP_IN_WORKPAPER".into(),
2785 edge_type_code: type_codes::AP_IN_WORKPAPER,
2786 properties: HashMap::new(),
2787 });
2788 }
2789 if let Some(acct_id) = &ap.account_id {
2790 self.edges.push(CrossLayerEdge {
2791 source_id: node_id,
2792 source_layer: HypergraphLayer::GovernanceControls,
2793 target_id: format!("acct_{acct_id}"),
2794 target_layer: HypergraphLayer::AccountingNetwork,
2795 edge_type: "AP_FOR_ACCOUNT".into(),
2796 edge_type_code: type_codes::AP_FOR_ACCOUNT,
2797 properties: HashMap::new(),
2798 });
2799 }
2800 }
2801 }
2802
2803 for iaf in ia_functions {
2805 let ext_id = iaf.function_id.to_string();
2806 let node_id = format!("audit_iaf_{ext_id}");
2807 let added = self.try_add_node(HypergraphNode {
2808 id: node_id.clone(),
2809 entity_type: "internal_audit_function".into(),
2810 entity_type_code: type_codes::INTERNAL_AUDIT_FUNCTION,
2811 layer: HypergraphLayer::GovernanceControls,
2812 external_id: ext_id.clone(),
2813 label: format!("IAF {}", iaf.function_ref),
2814 properties: {
2815 let mut p = HashMap::new();
2816 p.insert("entity_id".into(), Value::String(iaf.function_ref.clone()));
2817 p.insert("process_family".into(), Value::String("AUDIT".into()));
2818 p
2819 },
2820 features: vec![iaf.annual_plan_coverage],
2821 is_anomaly: false,
2822 anomaly_type: None,
2823 is_aggregate: false,
2824 aggregate_count: 0,
2825 });
2826 if added {
2827 self.edges.push(CrossLayerEdge {
2828 source_id: node_id,
2829 source_layer: HypergraphLayer::GovernanceControls,
2830 target_id: format!("audit_eng_{}", iaf.engagement_id),
2831 target_layer: HypergraphLayer::ProcessEvents,
2832 edge_type: "IAF_FOR_ENGAGEMENT".into(),
2833 edge_type_code: type_codes::IAF_FOR_ENGAGEMENT,
2834 properties: HashMap::new(),
2835 });
2836 }
2837 }
2838
2839 for iar in ia_reports {
2841 let ext_id = iar.report_id.to_string();
2842 let node_id = format!("audit_iar_{ext_id}");
2843 let added = self.try_add_node(HypergraphNode {
2844 id: node_id.clone(),
2845 entity_type: "internal_audit_report".into(),
2846 entity_type_code: type_codes::INTERNAL_AUDIT_REPORT,
2847 layer: HypergraphLayer::GovernanceControls,
2848 external_id: ext_id.clone(),
2849 label: format!("IAR {}", iar.report_ref),
2850 properties: {
2851 let mut p = HashMap::new();
2852 p.insert("entity_id".into(), Value::String(iar.report_ref.clone()));
2853 p.insert("process_family".into(), Value::String("AUDIT".into()));
2854 p
2855 },
2856 features: vec![],
2857 is_anomaly: false,
2858 anomaly_type: None,
2859 is_aggregate: false,
2860 aggregate_count: 0,
2861 });
2862 if added {
2863 self.edges.push(CrossLayerEdge {
2864 source_id: node_id.clone(),
2865 source_layer: HypergraphLayer::GovernanceControls,
2866 target_id: format!("audit_iaf_{}", iar.ia_function_id),
2867 target_layer: HypergraphLayer::GovernanceControls,
2868 edge_type: "REPORT_FROM_IAF".into(),
2869 edge_type_code: type_codes::REPORT_FROM_IAF,
2870 properties: HashMap::new(),
2871 });
2872 self.edges.push(CrossLayerEdge {
2873 source_id: node_id,
2874 source_layer: HypergraphLayer::GovernanceControls,
2875 target_id: format!("audit_eng_{}", iar.engagement_id),
2876 target_layer: HypergraphLayer::ProcessEvents,
2877 edge_type: "IA_REPORT_FOR_ENGAGEMENT".into(),
2878 edge_type_code: type_codes::IA_REPORT_FOR_ENGAGEMENT,
2879 properties: HashMap::new(),
2880 });
2881 }
2882 }
2883
2884 for rp in related_parties {
2886 let ext_id = rp.party_id.to_string();
2887 let node_id = format!("audit_rp_{ext_id}");
2888 let added = self.try_add_node(HypergraphNode {
2889 id: node_id.clone(),
2890 entity_type: "related_party".into(),
2891 entity_type_code: type_codes::RELATED_PARTY,
2892 layer: HypergraphLayer::GovernanceControls,
2893 external_id: ext_id.clone(),
2894 label: format!("RP {}", rp.party_ref),
2895 properties: {
2896 let mut p = HashMap::new();
2897 p.insert("entity_id".into(), Value::String(rp.party_ref.clone()));
2898 p.insert("process_family".into(), Value::String("AUDIT".into()));
2899 p
2900 },
2901 features: vec![],
2902 is_anomaly: false,
2903 anomaly_type: None,
2904 is_aggregate: false,
2905 aggregate_count: 0,
2906 });
2907 if added {
2908 self.edges.push(CrossLayerEdge {
2909 source_id: node_id,
2910 source_layer: HypergraphLayer::GovernanceControls,
2911 target_id: format!("audit_eng_{}", rp.engagement_id),
2912 target_layer: HypergraphLayer::ProcessEvents,
2913 edge_type: "RP_FOR_ENGAGEMENT".into(),
2914 edge_type_code: type_codes::RP_FOR_ENGAGEMENT,
2915 properties: HashMap::new(),
2916 });
2917 }
2918 }
2919
2920 for rpt in rp_transactions {
2922 let ext_id = rpt.transaction_id.to_string();
2923 let node_id = format!("audit_rpt_{ext_id}");
2924 let added = self.try_add_node(HypergraphNode {
2925 id: node_id.clone(),
2926 entity_type: "related_party_transaction".into(),
2927 entity_type_code: type_codes::RELATED_PARTY_TRANSACTION,
2928 layer: HypergraphLayer::ProcessEvents,
2929 external_id: ext_id.clone(),
2930 label: format!("RPT {}", rpt.transaction_ref),
2931 properties: {
2932 let mut p = HashMap::new();
2933 p.insert(
2934 "entity_id".into(),
2935 Value::String(rpt.transaction_ref.clone()),
2936 );
2937 p.insert("process_family".into(), Value::String("AUDIT".into()));
2938 p
2939 },
2940 features: vec![rpt
2941 .amount
2942 .to_string()
2943 .parse::<f64>()
2944 .unwrap_or(0.0)
2945 .abs()
2946 .ln_1p()],
2947 is_anomaly: rpt.management_override_risk,
2948 anomaly_type: if rpt.management_override_risk {
2949 Some("management_override_risk".into())
2950 } else {
2951 None
2952 },
2953 is_aggregate: false,
2954 aggregate_count: 0,
2955 });
2956 if added {
2957 self.edges.push(CrossLayerEdge {
2958 source_id: node_id,
2959 source_layer: HypergraphLayer::ProcessEvents,
2960 target_id: format!("audit_rp_{}", rpt.related_party_id),
2961 target_layer: HypergraphLayer::GovernanceControls,
2962 edge_type: "RPT_WITH_PARTY".into(),
2963 edge_type_code: type_codes::RPT_WITH_PARTY,
2964 properties: HashMap::new(),
2965 });
2966 }
2967 }
2968 }
2969
2970 pub fn add_bank_recon_documents(&mut self, reconciliations: &[BankReconciliation]) {
2972 if !self.config.include_r2r {
2973 return;
2974 }
2975 for recon in reconciliations {
2976 let node_id = format!("recon_{}", recon.reconciliation_id);
2977 self.try_add_node(HypergraphNode {
2978 id: node_id,
2979 entity_type: "bank_reconciliation".into(),
2980 entity_type_code: type_codes::BANK_RECONCILIATION,
2981 layer: HypergraphLayer::ProcessEvents,
2982 external_id: recon.reconciliation_id.clone(),
2983 label: format!("RECON {}", recon.reconciliation_id),
2984 properties: HashMap::new(),
2985 features: vec![recon
2986 .bank_ending_balance
2987 .to_string()
2988 .parse::<f64>()
2989 .unwrap_or(0.0)
2990 .ln_1p()],
2991 is_anomaly: false,
2992 anomaly_type: None,
2993 is_aggregate: false,
2994 aggregate_count: 0,
2995 });
2996 for line in &recon.statement_lines {
2997 let node_id = format!("recon_line_{}", line.line_id);
2998 self.try_add_node(HypergraphNode {
2999 id: node_id,
3000 entity_type: "bank_statement_line".into(),
3001 entity_type_code: type_codes::BANK_STATEMENT_LINE,
3002 layer: HypergraphLayer::ProcessEvents,
3003 external_id: line.line_id.clone(),
3004 label: format!("BSL {}", line.line_id),
3005 properties: HashMap::new(),
3006 features: vec![line
3007 .amount
3008 .to_string()
3009 .parse::<f64>()
3010 .unwrap_or(0.0)
3011 .abs()
3012 .ln_1p()],
3013 is_anomaly: false,
3014 anomaly_type: None,
3015 is_aggregate: false,
3016 aggregate_count: 0,
3017 });
3018 }
3019 for item in &recon.reconciling_items {
3020 let node_id = format!("recon_item_{}", item.item_id);
3021 self.try_add_node(HypergraphNode {
3022 id: node_id,
3023 entity_type: "reconciling_item".into(),
3024 entity_type_code: type_codes::RECONCILING_ITEM,
3025 layer: HypergraphLayer::ProcessEvents,
3026 external_id: item.item_id.clone(),
3027 label: format!("RITEM {}", item.item_id),
3028 properties: HashMap::new(),
3029 features: vec![item
3030 .amount
3031 .to_string()
3032 .parse::<f64>()
3033 .unwrap_or(0.0)
3034 .abs()
3035 .ln_1p()],
3036 is_anomaly: false,
3037 anomaly_type: None,
3038 is_aggregate: false,
3039 aggregate_count: 0,
3040 });
3041 }
3042 }
3043 }
3044
3045 pub fn add_ocpm_events(&mut self, event_log: &datasynth_ocpm::OcpmEventLog) {
3047 if !self.config.events_as_hyperedges {
3048 return;
3049 }
3050 for event in &event_log.events {
3051 let participants: Vec<HyperedgeParticipant> = event
3052 .object_refs
3053 .iter()
3054 .map(|obj_ref| {
3055 let node_id = format!("ocpm_obj_{}", obj_ref.object_id);
3056 self.try_add_node(HypergraphNode {
3058 id: node_id.clone(),
3059 entity_type: "ocpm_object".into(),
3060 entity_type_code: type_codes::OCPM_EVENT,
3061 layer: HypergraphLayer::ProcessEvents,
3062 external_id: obj_ref.object_id.to_string(),
3063 label: format!("OBJ {}", obj_ref.object_type_id),
3064 properties: HashMap::new(),
3065 features: vec![],
3066 is_anomaly: false,
3067 anomaly_type: None,
3068 is_aggregate: false,
3069 aggregate_count: 0,
3070 });
3071 HyperedgeParticipant {
3072 node_id,
3073 role: format!("{:?}", obj_ref.qualifier),
3074 weight: None,
3075 }
3076 })
3077 .collect();
3078
3079 if !participants.is_empty() {
3080 let mut props = HashMap::new();
3081 props.insert(
3082 "activity_id".into(),
3083 Value::String(event.activity_id.clone()),
3084 );
3085 props.insert(
3086 "timestamp".into(),
3087 Value::String(event.timestamp.to_rfc3339()),
3088 );
3089 if !event.resource_id.is_empty() {
3090 props.insert("resource".into(), Value::String(event.resource_id.clone()));
3091 }
3092
3093 self.hyperedges.push(Hyperedge {
3094 id: format!("ocpm_evt_{}", event.event_id),
3095 hyperedge_type: "OcpmEvent".into(),
3096 subtype: event.activity_id.clone(),
3097 participants,
3098 layer: HypergraphLayer::ProcessEvents,
3099 properties: props,
3100 timestamp: Some(event.timestamp.date_naive()),
3101 is_anomaly: false,
3102 anomaly_type: None,
3103 features: vec![],
3104 });
3105 }
3106 }
3107 }
3108
3109 pub fn add_compliance_regulations(
3116 &mut self,
3117 standards: &[ComplianceStandard],
3118 findings: &[ComplianceFinding],
3119 filings: &[RegulatoryFiling],
3120 ) {
3121 if !self.config.include_compliance {
3122 return;
3123 }
3124
3125 for std in standards {
3127 if std.is_superseded() {
3128 continue;
3129 }
3130 let sid = std.id.as_str().to_string();
3131 let node_id = format!("cr_std_{sid}");
3132 if self.try_add_node(HypergraphNode {
3133 id: node_id.clone(),
3134 entity_type: "compliance_standard".into(),
3135 entity_type_code: type_codes::COMPLIANCE_STANDARD,
3136 layer: HypergraphLayer::GovernanceControls,
3137 external_id: sid.clone(),
3138 label: format!("{}: {}", sid, std.title),
3139 properties: {
3140 let mut p = HashMap::new();
3141 p.insert("title".into(), Value::String(std.title.clone()));
3142 p.insert("category".into(), Value::String(std.category.to_string()));
3143 p.insert("domain".into(), Value::String(std.domain.to_string()));
3144 p.insert(
3145 "issuingBody".into(),
3146 Value::String(std.issuing_body.to_string()),
3147 );
3148 if !std.applicable_account_types.is_empty() {
3149 p.insert(
3150 "applicableAccountTypes".into(),
3151 Value::Array(
3152 std.applicable_account_types
3153 .iter()
3154 .map(|s| Value::String(s.clone()))
3155 .collect(),
3156 ),
3157 );
3158 }
3159 if !std.applicable_processes.is_empty() {
3160 p.insert(
3161 "applicableProcesses".into(),
3162 Value::Array(
3163 std.applicable_processes
3164 .iter()
3165 .map(|s| Value::String(s.clone()))
3166 .collect(),
3167 ),
3168 );
3169 }
3170 p
3171 },
3172 features: vec![
3173 std.versions.len() as f64,
3174 std.requirements.len() as f64,
3175 std.mandatory_jurisdictions.len() as f64,
3176 ],
3177 is_anomaly: false,
3178 anomaly_type: None,
3179 is_aggregate: false,
3180 aggregate_count: 0,
3181 }) {
3182 self.standard_node_ids.insert(sid.clone(), node_id.clone());
3183
3184 for _acct_type in &std.applicable_account_types {
3186 }
3189 }
3190 }
3191
3192 for finding in findings {
3194 let fid = finding.finding_id.to_string();
3195 let node_id = format!("cr_find_{fid}");
3196 if self.try_add_node(HypergraphNode {
3197 id: node_id.clone(),
3198 entity_type: "compliance_finding".into(),
3199 entity_type_code: type_codes::COMPLIANCE_FINDING,
3200 layer: HypergraphLayer::ProcessEvents,
3201 external_id: fid,
3202 label: format!("CF {} [{}]", finding.deficiency_level, finding.company_code),
3203 properties: {
3204 let mut p = HashMap::new();
3205 p.insert("title".into(), Value::String(finding.title.clone()));
3206 p.insert(
3207 "severity".into(),
3208 Value::String(finding.severity.to_string()),
3209 );
3210 p.insert(
3211 "deficiencyLevel".into(),
3212 Value::String(finding.deficiency_level.to_string()),
3213 );
3214 p.insert(
3215 "companyCode".into(),
3216 Value::String(finding.company_code.clone()),
3217 );
3218 p.insert(
3219 "remediationStatus".into(),
3220 Value::String(finding.remediation_status.to_string()),
3221 );
3222 p.insert("isRepeat".into(), Value::Bool(finding.is_repeat));
3223 p.insert(
3224 "identifiedDate".into(),
3225 Value::String(finding.identified_date.to_string()),
3226 );
3227 p
3228 },
3229 features: vec![
3230 finding.severity.score(),
3231 finding.deficiency_level.severity_score(),
3232 if finding.is_repeat { 1.0 } else { 0.0 },
3233 ],
3234 is_anomaly: false,
3235 anomaly_type: None,
3236 is_aggregate: false,
3237 aggregate_count: 0,
3238 }) {
3239 for std_id in &finding.related_standards {
3241 let sid = std_id.as_str().to_string();
3242 if let Some(std_node) = self.standard_node_ids.get(&sid) {
3243 self.edges.push(CrossLayerEdge {
3244 source_id: node_id.clone(),
3245 source_layer: HypergraphLayer::ProcessEvents,
3246 target_id: std_node.clone(),
3247 target_layer: HypergraphLayer::GovernanceControls,
3248 edge_type: "FindingOnStandard".to_string(),
3249 edge_type_code: type_codes::GOVERNED_BY_STANDARD,
3250 properties: HashMap::new(),
3251 });
3252 }
3253 }
3254
3255 if let Some(ref ctrl_id) = finding.control_id {
3257 self.compliance_finding_control_links
3258 .push((node_id, ctrl_id.clone()));
3259 }
3260 }
3261 }
3262
3263 for filing in filings {
3265 let filing_key = format!(
3266 "{}_{}_{}_{}",
3267 filing.filing_type, filing.company_code, filing.jurisdiction, filing.period_end
3268 );
3269 let node_id = format!("cr_filing_{filing_key}");
3270 self.try_add_node(HypergraphNode {
3271 id: node_id,
3272 entity_type: "regulatory_filing".into(),
3273 entity_type_code: type_codes::REGULATORY_FILING,
3274 layer: HypergraphLayer::ProcessEvents,
3275 external_id: filing_key,
3276 label: format!("{} [{}]", filing.filing_type, filing.company_code),
3277 properties: {
3278 let mut p = HashMap::new();
3279 p.insert(
3280 "filingType".into(),
3281 Value::String(filing.filing_type.to_string()),
3282 );
3283 p.insert(
3284 "companyCode".into(),
3285 Value::String(filing.company_code.clone()),
3286 );
3287 p.insert(
3288 "jurisdiction".into(),
3289 Value::String(filing.jurisdiction.clone()),
3290 );
3291 p.insert(
3292 "status".into(),
3293 Value::String(format!("{:?}", filing.status)),
3294 );
3295 p.insert(
3296 "periodEnd".into(),
3297 Value::String(filing.period_end.to_string()),
3298 );
3299 p.insert(
3300 "deadline".into(),
3301 Value::String(filing.deadline.to_string()),
3302 );
3303 p
3304 },
3305 features: vec![],
3306 is_anomaly: false,
3307 anomaly_type: None,
3308 is_aggregate: false,
3309 aggregate_count: 0,
3310 });
3311 }
3312 }
3313
3314 #[allow(clippy::too_many_arguments)]
3323 pub fn add_tax_documents(
3324 &mut self,
3325 jurisdictions: &[TaxJurisdiction],
3326 codes: &[TaxCode],
3327 tax_lines: &[TaxLine],
3328 tax_returns: &[TaxReturn],
3329 tax_provisions: &[TaxProvision],
3330 withholding_records: &[WithholdingTaxRecord],
3331 ) {
3332 if !self.config.include_tax {
3333 return;
3334 }
3335
3336 for jur in jurisdictions {
3337 let node_id = format!("tax_jur_{}", jur.id);
3338 self.try_add_node(HypergraphNode {
3339 id: node_id,
3340 entity_type: "tax_jurisdiction".into(),
3341 entity_type_code: type_codes::TAX_JURISDICTION,
3342 layer: HypergraphLayer::AccountingNetwork,
3343 external_id: jur.id.clone(),
3344 label: jur.name.clone(),
3345 properties: {
3346 let mut p = HashMap::new();
3347 p.insert(
3348 "country_code".into(),
3349 Value::String(jur.country_code.clone()),
3350 );
3351 p.insert(
3352 "jurisdiction_type".into(),
3353 Value::String(format!("{:?}", jur.jurisdiction_type)),
3354 );
3355 p.insert("vat_registered".into(), Value::Bool(jur.vat_registered));
3356 if let Some(ref region) = jur.region_code {
3357 p.insert("region_code".into(), Value::String(region.clone()));
3358 }
3359 p
3360 },
3361 features: vec![if jur.vat_registered { 1.0 } else { 0.0 }],
3362 is_anomaly: false,
3363 anomaly_type: None,
3364 is_aggregate: false,
3365 aggregate_count: 0,
3366 });
3367 }
3368
3369 for code in codes {
3370 let node_id = format!("tax_code_{}", code.id);
3371 self.try_add_node(HypergraphNode {
3372 id: node_id,
3373 entity_type: "tax_code".into(),
3374 entity_type_code: type_codes::TAX_CODE,
3375 layer: HypergraphLayer::AccountingNetwork,
3376 external_id: code.id.clone(),
3377 label: format!("{} ({})", code.code, code.description),
3378 properties: {
3379 let mut p = HashMap::new();
3380 p.insert("code".into(), Value::String(code.code.clone()));
3381 p.insert(
3382 "tax_type".into(),
3383 Value::String(format!("{:?}", code.tax_type)),
3384 );
3385 let rate: f64 = code.rate.to_string().parse().unwrap_or(0.0);
3386 p.insert("rate".into(), serde_json::json!(rate));
3387 p.insert(
3388 "jurisdiction_id".into(),
3389 Value::String(code.jurisdiction_id.clone()),
3390 );
3391 p.insert("is_exempt".into(), Value::Bool(code.is_exempt));
3392 p.insert(
3393 "is_reverse_charge".into(),
3394 Value::Bool(code.is_reverse_charge),
3395 );
3396 p
3397 },
3398 features: vec![code.rate.to_string().parse::<f64>().unwrap_or(0.0)],
3399 is_anomaly: false,
3400 anomaly_type: None,
3401 is_aggregate: false,
3402 aggregate_count: 0,
3403 });
3404 }
3405
3406 for line in tax_lines {
3407 let node_id = format!("tax_line_{}", line.id);
3408 self.try_add_node(HypergraphNode {
3409 id: node_id,
3410 entity_type: "tax_line".into(),
3411 entity_type_code: type_codes::TAX_LINE,
3412 layer: HypergraphLayer::AccountingNetwork,
3413 external_id: line.id.clone(),
3414 label: format!("TAXL {} L{}", line.document_id, line.line_number),
3415 properties: {
3416 let mut p = HashMap::new();
3417 p.insert(
3418 "document_type".into(),
3419 Value::String(format!("{:?}", line.document_type)),
3420 );
3421 p.insert(
3422 "document_id".into(),
3423 Value::String(line.document_id.clone()),
3424 );
3425 p.insert(
3426 "tax_code_id".into(),
3427 Value::String(line.tax_code_id.clone()),
3428 );
3429 let amt: f64 = line.tax_amount.to_string().parse().unwrap_or(0.0);
3430 p.insert("tax_amount".into(), serde_json::json!(amt));
3431 p
3432 },
3433 features: vec![line
3434 .tax_amount
3435 .to_string()
3436 .parse::<f64>()
3437 .unwrap_or(0.0)
3438 .abs()
3439 .ln_1p()],
3440 is_anomaly: false,
3441 anomaly_type: None,
3442 is_aggregate: false,
3443 aggregate_count: 0,
3444 });
3445 }
3446
3447 for ret in tax_returns {
3448 let node_id = format!("tax_ret_{}", ret.id);
3449 self.try_add_node(HypergraphNode {
3450 id: node_id,
3451 entity_type: "tax_return".into(),
3452 entity_type_code: type_codes::TAX_RETURN,
3453 layer: HypergraphLayer::AccountingNetwork,
3454 external_id: ret.id.clone(),
3455 label: format!("TAXR {} [{:?}]", ret.entity_id, ret.return_type),
3456 properties: {
3457 let mut p = HashMap::new();
3458 p.insert("entity_id".into(), Value::String(ret.entity_id.clone()));
3459 p.insert(
3460 "jurisdiction_id".into(),
3461 Value::String(ret.jurisdiction_id.clone()),
3462 );
3463 p.insert(
3464 "return_type".into(),
3465 Value::String(format!("{:?}", ret.return_type)),
3466 );
3467 p.insert("status".into(), Value::String(format!("{:?}", ret.status)));
3468 p.insert(
3469 "period_start".into(),
3470 Value::String(ret.period_start.to_string()),
3471 );
3472 p.insert(
3473 "period_end".into(),
3474 Value::String(ret.period_end.to_string()),
3475 );
3476 p.insert("is_late".into(), Value::Bool(ret.is_late));
3477 let net: f64 = ret.net_payable.to_string().parse().unwrap_or(0.0);
3478 p.insert("net_payable".into(), serde_json::json!(net));
3479 p
3480 },
3481 features: vec![
3482 ret.net_payable
3483 .to_string()
3484 .parse::<f64>()
3485 .unwrap_or(0.0)
3486 .abs()
3487 .ln_1p(),
3488 if ret.is_late { 1.0 } else { 0.0 },
3489 ],
3490 is_anomaly: ret.is_late,
3491 anomaly_type: if ret.is_late {
3492 Some("late_filing".into())
3493 } else {
3494 None
3495 },
3496 is_aggregate: false,
3497 aggregate_count: 0,
3498 });
3499 }
3500
3501 for prov in tax_provisions {
3502 let node_id = format!("tax_prov_{}", prov.id);
3503 self.try_add_node(HypergraphNode {
3504 id: node_id,
3505 entity_type: "tax_provision".into(),
3506 entity_type_code: type_codes::TAX_PROVISION,
3507 layer: HypergraphLayer::AccountingNetwork,
3508 external_id: prov.id.clone(),
3509 label: format!("TAXPROV {} {}", prov.entity_id, prov.period),
3510 properties: {
3511 let mut p = HashMap::new();
3512 p.insert("entity_id".into(), Value::String(prov.entity_id.clone()));
3513 p.insert("period".into(), Value::String(prov.period.to_string()));
3514 let eff: f64 = prov.effective_rate.to_string().parse().unwrap_or(0.0);
3515 p.insert("effective_rate".into(), serde_json::json!(eff));
3516 let stat: f64 = prov.statutory_rate.to_string().parse().unwrap_or(0.0);
3517 p.insert("statutory_rate".into(), serde_json::json!(stat));
3518 let expense: f64 = prov.current_tax_expense.to_string().parse().unwrap_or(0.0);
3519 p.insert("current_tax_expense".into(), serde_json::json!(expense));
3520 p
3521 },
3522 features: vec![
3523 prov.effective_rate
3524 .to_string()
3525 .parse::<f64>()
3526 .unwrap_or(0.0),
3527 prov.current_tax_expense
3528 .to_string()
3529 .parse::<f64>()
3530 .unwrap_or(0.0)
3531 .abs()
3532 .ln_1p(),
3533 ],
3534 is_anomaly: false,
3535 anomaly_type: None,
3536 is_aggregate: false,
3537 aggregate_count: 0,
3538 });
3539 }
3540
3541 for wht in withholding_records {
3542 let node_id = format!("tax_wht_{}", wht.id);
3543 self.try_add_node(HypergraphNode {
3544 id: node_id,
3545 entity_type: "withholding_tax_record".into(),
3546 entity_type_code: type_codes::WITHHOLDING_TAX,
3547 layer: HypergraphLayer::AccountingNetwork,
3548 external_id: wht.id.clone(),
3549 label: format!("WHT {} → {}", wht.payment_id, wht.vendor_id),
3550 properties: {
3551 let mut p = HashMap::new();
3552 p.insert("payment_id".into(), Value::String(wht.payment_id.clone()));
3553 p.insert("vendor_id".into(), Value::String(wht.vendor_id.clone()));
3554 p.insert(
3555 "withholding_type".into(),
3556 Value::String(format!("{:?}", wht.withholding_type)),
3557 );
3558 let amt: f64 = wht.withheld_amount.to_string().parse().unwrap_or(0.0);
3559 p.insert("withheld_amount".into(), serde_json::json!(amt));
3560 let rate: f64 = wht.applied_rate.to_string().parse().unwrap_or(0.0);
3561 p.insert("applied_rate".into(), serde_json::json!(rate));
3562 p
3563 },
3564 features: vec![wht
3565 .withheld_amount
3566 .to_string()
3567 .parse::<f64>()
3568 .unwrap_or(0.0)
3569 .abs()
3570 .ln_1p()],
3571 is_anomaly: false,
3572 anomaly_type: None,
3573 is_aggregate: false,
3574 aggregate_count: 0,
3575 });
3576 }
3577 }
3578
3579 pub fn add_treasury_documents(
3584 &mut self,
3585 cash_positions: &[CashPosition],
3586 cash_forecasts: &[CashForecast],
3587 hedge_relationships: &[HedgeRelationship],
3588 debt_instruments: &[DebtInstrument],
3589 ) {
3590 if !self.config.include_treasury {
3591 return;
3592 }
3593
3594 for pos in cash_positions {
3595 let node_id = format!("treas_pos_{}", pos.id);
3596 self.try_add_node(HypergraphNode {
3597 id: node_id,
3598 entity_type: "cash_position".into(),
3599 entity_type_code: type_codes::CASH_POSITION,
3600 layer: HypergraphLayer::AccountingNetwork,
3601 external_id: pos.id.clone(),
3602 label: format!("CPOS {} {}", pos.bank_account_id, pos.date),
3603 properties: {
3604 let mut p = HashMap::new();
3605 p.insert("entity_id".into(), Value::String(pos.entity_id.clone()));
3606 p.insert(
3607 "bank_account_id".into(),
3608 Value::String(pos.bank_account_id.clone()),
3609 );
3610 p.insert("currency".into(), Value::String(pos.currency.clone()));
3611 p.insert("date".into(), Value::String(pos.date.to_string()));
3612 let closing: f64 = pos.closing_balance.to_string().parse().unwrap_or(0.0);
3613 p.insert("closing_balance".into(), serde_json::json!(closing));
3614 p
3615 },
3616 features: vec![pos
3617 .closing_balance
3618 .to_string()
3619 .parse::<f64>()
3620 .unwrap_or(0.0)
3621 .abs()
3622 .ln_1p()],
3623 is_anomaly: false,
3624 anomaly_type: None,
3625 is_aggregate: false,
3626 aggregate_count: 0,
3627 });
3628 }
3629
3630 for fc in cash_forecasts {
3631 let node_id = format!("treas_fc_{}", fc.id);
3632 self.try_add_node(HypergraphNode {
3633 id: node_id,
3634 entity_type: "cash_forecast".into(),
3635 entity_type_code: type_codes::CASH_FORECAST,
3636 layer: HypergraphLayer::AccountingNetwork,
3637 external_id: fc.id.clone(),
3638 label: format!("CFOR {} {}d", fc.entity_id, fc.horizon_days),
3639 properties: {
3640 let mut p = HashMap::new();
3641 p.insert("entity_id".into(), Value::String(fc.entity_id.clone()));
3642 p.insert("currency".into(), Value::String(fc.currency.clone()));
3643 p.insert(
3644 "forecast_date".into(),
3645 Value::String(fc.forecast_date.to_string()),
3646 );
3647 p.insert(
3648 "horizon_days".into(),
3649 Value::Number((fc.horizon_days as u64).into()),
3650 );
3651 let net: f64 = fc.net_position.to_string().parse().unwrap_or(0.0);
3652 p.insert("net_position".into(), serde_json::json!(net));
3653 let conf: f64 = fc.confidence_level.to_string().parse().unwrap_or(0.0);
3654 p.insert("confidence_level".into(), serde_json::json!(conf));
3655 p
3656 },
3657 features: vec![
3658 fc.net_position
3659 .to_string()
3660 .parse::<f64>()
3661 .unwrap_or(0.0)
3662 .abs()
3663 .ln_1p(),
3664 fc.confidence_level
3665 .to_string()
3666 .parse::<f64>()
3667 .unwrap_or(0.0),
3668 ],
3669 is_anomaly: false,
3670 anomaly_type: None,
3671 is_aggregate: false,
3672 aggregate_count: 0,
3673 });
3674 }
3675
3676 for hr in hedge_relationships {
3677 let node_id = format!("treas_hedge_{}", hr.id);
3678 self.try_add_node(HypergraphNode {
3679 id: node_id,
3680 entity_type: "hedge_relationship".into(),
3681 entity_type_code: type_codes::HEDGE_RELATIONSHIP,
3682 layer: HypergraphLayer::AccountingNetwork,
3683 external_id: hr.id.clone(),
3684 label: format!("HEDGE {:?} {}", hr.hedge_type, hr.hedged_item_description),
3685 properties: {
3686 let mut p = HashMap::new();
3687 p.insert(
3688 "hedged_item_type".into(),
3689 Value::String(format!("{:?}", hr.hedged_item_type)),
3690 );
3691 p.insert(
3692 "hedge_type".into(),
3693 Value::String(format!("{:?}", hr.hedge_type)),
3694 );
3695 p.insert(
3696 "designation_date".into(),
3697 Value::String(hr.designation_date.to_string()),
3698 );
3699 p.insert("is_effective".into(), Value::Bool(hr.is_effective));
3700 let ratio: f64 = hr.effectiveness_ratio.to_string().parse().unwrap_or(0.0);
3701 p.insert("effectiveness_ratio".into(), serde_json::json!(ratio));
3702 p
3703 },
3704 features: vec![
3705 hr.effectiveness_ratio
3706 .to_string()
3707 .parse::<f64>()
3708 .unwrap_or(0.0),
3709 if hr.is_effective { 1.0 } else { 0.0 },
3710 ],
3711 is_anomaly: !hr.is_effective,
3712 anomaly_type: if !hr.is_effective {
3713 Some("ineffective_hedge".into())
3714 } else {
3715 None
3716 },
3717 is_aggregate: false,
3718 aggregate_count: 0,
3719 });
3720 }
3721
3722 for debt in debt_instruments {
3723 let node_id = format!("treas_debt_{}", debt.id);
3724 self.try_add_node(HypergraphNode {
3725 id: node_id,
3726 entity_type: "debt_instrument".into(),
3727 entity_type_code: type_codes::DEBT_INSTRUMENT,
3728 layer: HypergraphLayer::AccountingNetwork,
3729 external_id: debt.id.clone(),
3730 label: format!("DEBT {:?} {}", debt.instrument_type, debt.lender),
3731 properties: {
3732 let mut p = HashMap::new();
3733 p.insert("entity_id".into(), Value::String(debt.entity_id.clone()));
3734 p.insert(
3735 "instrument_type".into(),
3736 Value::String(format!("{:?}", debt.instrument_type)),
3737 );
3738 p.insert("lender".into(), Value::String(debt.lender.clone()));
3739 p.insert("currency".into(), Value::String(debt.currency.clone()));
3740 let principal: f64 = debt.principal.to_string().parse().unwrap_or(0.0);
3741 p.insert("principal".into(), serde_json::json!(principal));
3742 let rate: f64 = debt.interest_rate.to_string().parse().unwrap_or(0.0);
3743 p.insert("interest_rate".into(), serde_json::json!(rate));
3744 p.insert(
3745 "maturity_date".into(),
3746 Value::String(debt.maturity_date.to_string()),
3747 );
3748 p.insert(
3749 "covenant_count".into(),
3750 Value::Number((debt.covenants.len() as u64).into()),
3751 );
3752 p
3753 },
3754 features: vec![
3755 debt.principal
3756 .to_string()
3757 .parse::<f64>()
3758 .unwrap_or(0.0)
3759 .ln_1p(),
3760 debt.interest_rate.to_string().parse::<f64>().unwrap_or(0.0),
3761 ],
3762 is_anomaly: false,
3763 anomaly_type: None,
3764 is_aggregate: false,
3765 aggregate_count: 0,
3766 });
3767 }
3768 }
3769
3770 pub fn add_esg_documents(
3775 &mut self,
3776 emissions: &[EmissionRecord],
3777 disclosures: &[EsgDisclosure],
3778 supplier_assessments: &[SupplierEsgAssessment],
3779 climate_scenarios: &[ClimateScenario],
3780 ) {
3781 if !self.config.include_esg {
3782 return;
3783 }
3784
3785 for em in emissions {
3786 let node_id = format!("esg_em_{}", em.id);
3787 self.try_add_node(HypergraphNode {
3788 id: node_id,
3789 entity_type: "emission_record".into(),
3790 entity_type_code: type_codes::EMISSION_RECORD,
3791 layer: HypergraphLayer::GovernanceControls,
3792 external_id: em.id.clone(),
3793 label: format!("EM {:?} {}", em.scope, em.period),
3794 properties: {
3795 let mut p = HashMap::new();
3796 p.insert("entity_id".into(), Value::String(em.entity_id.clone()));
3797 p.insert("scope".into(), Value::String(format!("{:?}", em.scope)));
3798 p.insert("period".into(), Value::String(em.period.to_string()));
3799 let co2e: f64 = em.co2e_tonnes.to_string().parse().unwrap_or(0.0);
3800 p.insert("co2e_tonnes".into(), serde_json::json!(co2e));
3801 p.insert(
3802 "estimation_method".into(),
3803 Value::String(format!("{:?}", em.estimation_method)),
3804 );
3805 if let Some(ref fid) = em.facility_id {
3806 p.insert("facility_id".into(), Value::String(String::clone(fid)));
3807 }
3808 p
3809 },
3810 features: vec![em
3811 .co2e_tonnes
3812 .to_string()
3813 .parse::<f64>()
3814 .unwrap_or(0.0)
3815 .ln_1p()],
3816 is_anomaly: false,
3817 anomaly_type: None,
3818 is_aggregate: false,
3819 aggregate_count: 0,
3820 });
3821 }
3822
3823 for disc in disclosures {
3824 let node_id = format!("esg_disc_{}", disc.id);
3825 self.try_add_node(HypergraphNode {
3826 id: node_id,
3827 entity_type: "esg_disclosure".into(),
3828 entity_type_code: type_codes::ESG_DISCLOSURE,
3829 layer: HypergraphLayer::GovernanceControls,
3830 external_id: disc.id.clone(),
3831 label: format!("{:?}: {}", disc.framework, disc.disclosure_topic),
3832 properties: {
3833 let mut p = HashMap::new();
3834 p.insert("entity_id".into(), Value::String(disc.entity_id.clone()));
3835 p.insert(
3836 "framework".into(),
3837 Value::String(format!("{:?}", disc.framework)),
3838 );
3839 p.insert(
3840 "disclosure_topic".into(),
3841 Value::String(disc.disclosure_topic.clone()),
3842 );
3843 p.insert(
3844 "assurance_level".into(),
3845 Value::String(format!("{:?}", disc.assurance_level)),
3846 );
3847 p.insert("is_assured".into(), Value::Bool(disc.is_assured));
3848 p.insert(
3849 "reporting_period_start".into(),
3850 Value::String(disc.reporting_period_start.to_string()),
3851 );
3852 p.insert(
3853 "reporting_period_end".into(),
3854 Value::String(disc.reporting_period_end.to_string()),
3855 );
3856 p
3857 },
3858 features: vec![if disc.is_assured { 1.0 } else { 0.0 }],
3859 is_anomaly: false,
3860 anomaly_type: None,
3861 is_aggregate: false,
3862 aggregate_count: 0,
3863 });
3864 }
3865
3866 for sa in supplier_assessments {
3867 let node_id = format!("esg_sa_{}", sa.id);
3868 self.try_add_node(HypergraphNode {
3869 id: node_id,
3870 entity_type: "supplier_esg_assessment".into(),
3871 entity_type_code: type_codes::SUPPLIER_ESG_ASSESSMENT,
3872 layer: HypergraphLayer::GovernanceControls,
3873 external_id: sa.id.clone(),
3874 label: format!("ESG-SA {} ({})", sa.vendor_id, sa.assessment_date),
3875 properties: {
3876 let mut p = HashMap::new();
3877 p.insert("entity_id".into(), Value::String(sa.entity_id.clone()));
3878 p.insert("vendor_id".into(), Value::String(sa.vendor_id.clone()));
3879 p.insert(
3880 "assessment_date".into(),
3881 Value::String(sa.assessment_date.to_string()),
3882 );
3883 let overall: f64 = sa.overall_score.to_string().parse().unwrap_or(0.0);
3884 p.insert("overall_score".into(), serde_json::json!(overall));
3885 p.insert(
3886 "risk_flag".into(),
3887 Value::String(format!("{:?}", sa.risk_flag)),
3888 );
3889 p
3890 },
3891 features: vec![sa.overall_score.to_string().parse::<f64>().unwrap_or(0.0)],
3892 is_anomaly: false,
3893 anomaly_type: None,
3894 is_aggregate: false,
3895 aggregate_count: 0,
3896 });
3897 }
3898
3899 for cs in climate_scenarios {
3900 let node_id = format!("esg_cs_{}", cs.id);
3901 self.try_add_node(HypergraphNode {
3902 id: node_id,
3903 entity_type: "climate_scenario".into(),
3904 entity_type_code: type_codes::CLIMATE_SCENARIO,
3905 layer: HypergraphLayer::GovernanceControls,
3906 external_id: cs.id.clone(),
3907 label: format!("{:?} {:?}", cs.scenario_type, cs.time_horizon),
3908 properties: {
3909 let mut p = HashMap::new();
3910 p.insert("entity_id".into(), Value::String(cs.entity_id.clone()));
3911 p.insert(
3912 "scenario_type".into(),
3913 Value::String(format!("{:?}", cs.scenario_type)),
3914 );
3915 p.insert(
3916 "time_horizon".into(),
3917 Value::String(format!("{:?}", cs.time_horizon)),
3918 );
3919 p.insert("description".into(), Value::String(cs.description.clone()));
3920 let temp: f64 = cs.temperature_rise_c.to_string().parse().unwrap_or(0.0);
3921 p.insert("temperature_rise_c".into(), serde_json::json!(temp));
3922 let fin: f64 = cs.financial_impact.to_string().parse().unwrap_or(0.0);
3923 p.insert("financial_impact".into(), serde_json::json!(fin));
3924 p
3925 },
3926 features: vec![
3927 cs.temperature_rise_c
3928 .to_string()
3929 .parse::<f64>()
3930 .unwrap_or(0.0),
3931 cs.financial_impact
3932 .to_string()
3933 .parse::<f64>()
3934 .unwrap_or(0.0)
3935 .abs()
3936 .ln_1p(),
3937 ],
3938 is_anomaly: false,
3939 anomaly_type: None,
3940 is_aggregate: false,
3941 aggregate_count: 0,
3942 });
3943 }
3944 }
3945
3946 pub fn add_project_documents(
3950 &mut self,
3951 projects: &[Project],
3952 earned_value_metrics: &[EarnedValueMetric],
3953 milestones: &[ProjectMilestone],
3954 ) {
3955 if !self.config.include_project {
3956 return;
3957 }
3958
3959 for proj in projects {
3960 let node_id = format!("proj_{}", proj.project_id);
3961 self.try_add_node(HypergraphNode {
3962 id: node_id,
3963 entity_type: "project".into(),
3964 entity_type_code: type_codes::PROJECT,
3965 layer: HypergraphLayer::AccountingNetwork,
3966 external_id: proj.project_id.clone(),
3967 label: format!("{} ({})", proj.name, proj.project_id),
3968 properties: {
3969 let mut p = HashMap::new();
3970 p.insert("name".into(), Value::String(proj.name.clone()));
3971 p.insert(
3972 "project_type".into(),
3973 Value::String(format!("{:?}", proj.project_type)),
3974 );
3975 p.insert("status".into(), Value::String(format!("{:?}", proj.status)));
3976 p.insert(
3977 "company_code".into(),
3978 Value::String(proj.company_code.clone()),
3979 );
3980 let budget: f64 = proj.budget.to_string().parse().unwrap_or(0.0);
3981 p.insert("budget".into(), serde_json::json!(budget));
3982 p
3983 },
3984 features: vec![proj
3985 .budget
3986 .to_string()
3987 .parse::<f64>()
3988 .unwrap_or(0.0)
3989 .ln_1p()],
3990 is_anomaly: false,
3991 anomaly_type: None,
3992 is_aggregate: false,
3993 aggregate_count: 0,
3994 });
3995 }
3996
3997 for evm in earned_value_metrics {
3998 let node_id = format!("proj_evm_{}", evm.id);
3999 let spi: f64 = evm.spi.to_string().parse().unwrap_or(1.0);
4000 let cpi: f64 = evm.cpi.to_string().parse().unwrap_or(1.0);
4001 let is_anomaly = spi < 0.8 || cpi < 0.8;
4003 self.try_add_node(HypergraphNode {
4004 id: node_id,
4005 entity_type: "earned_value_metric".into(),
4006 entity_type_code: type_codes::EARNED_VALUE,
4007 layer: HypergraphLayer::AccountingNetwork,
4008 external_id: evm.id.clone(),
4009 label: format!("EVM {} {}", evm.project_id, evm.measurement_date),
4010 properties: {
4011 let mut p = HashMap::new();
4012 p.insert("project_id".into(), Value::String(evm.project_id.clone()));
4013 p.insert(
4014 "measurement_date".into(),
4015 Value::String(evm.measurement_date.to_string()),
4016 );
4017 p.insert("spi".into(), serde_json::json!(spi));
4018 p.insert("cpi".into(), serde_json::json!(cpi));
4019 let eac: f64 = evm.eac.to_string().parse().unwrap_or(0.0);
4020 p.insert("eac".into(), serde_json::json!(eac));
4021 p
4022 },
4023 features: vec![spi, cpi],
4024 is_anomaly,
4025 anomaly_type: if is_anomaly {
4026 Some("poor_project_performance".into())
4027 } else {
4028 None
4029 },
4030 is_aggregate: false,
4031 aggregate_count: 0,
4032 });
4033 }
4034
4035 for ms in milestones {
4036 let node_id = format!("proj_ms_{}", ms.id);
4037 self.try_add_node(HypergraphNode {
4038 id: node_id,
4039 entity_type: "project_milestone".into(),
4040 entity_type_code: type_codes::PROJECT_MILESTONE,
4041 layer: HypergraphLayer::AccountingNetwork,
4042 external_id: ms.id.clone(),
4043 label: format!("MS {} ({})", ms.name, ms.project_id),
4044 properties: {
4045 let mut p = HashMap::new();
4046 p.insert("project_id".into(), Value::String(ms.project_id.clone()));
4047 p.insert("name".into(), Value::String(ms.name.clone()));
4048 p.insert(
4049 "planned_date".into(),
4050 Value::String(ms.planned_date.to_string()),
4051 );
4052 p.insert("status".into(), Value::String(format!("{:?}", ms.status)));
4053 p.insert(
4054 "sequence".into(),
4055 Value::Number((ms.sequence as u64).into()),
4056 );
4057 let amt: f64 = ms.payment_amount.to_string().parse().unwrap_or(0.0);
4058 p.insert("payment_amount".into(), serde_json::json!(amt));
4059 if let Some(ref actual) = ms.actual_date {
4060 p.insert("actual_date".into(), Value::String(actual.to_string()));
4061 }
4062 p
4063 },
4064 features: vec![ms
4065 .payment_amount
4066 .to_string()
4067 .parse::<f64>()
4068 .unwrap_or(0.0)
4069 .ln_1p()],
4070 is_anomaly: false,
4071 anomaly_type: None,
4072 is_aggregate: false,
4073 aggregate_count: 0,
4074 });
4075 }
4076 }
4077
4078 pub fn add_intercompany_documents(
4082 &mut self,
4083 matched_pairs: &[ICMatchedPair],
4084 elimination_entries: &[EliminationEntry],
4085 ) {
4086 if !self.config.include_intercompany {
4087 return;
4088 }
4089
4090 for pair in matched_pairs {
4091 let node_id = format!("ic_pair_{}", pair.ic_reference);
4092 self.try_add_node(HypergraphNode {
4093 id: node_id,
4094 entity_type: "ic_matched_pair".into(),
4095 entity_type_code: type_codes::IC_MATCHED_PAIR,
4096 layer: HypergraphLayer::AccountingNetwork,
4097 external_id: pair.ic_reference.clone(),
4098 label: format!("IC {} → {}", pair.seller_company, pair.buyer_company),
4099 properties: {
4100 let mut p = HashMap::new();
4101 p.insert(
4102 "transaction_type".into(),
4103 Value::String(format!("{:?}", pair.transaction_type)),
4104 );
4105 p.insert(
4106 "seller_company".into(),
4107 Value::String(pair.seller_company.clone()),
4108 );
4109 p.insert(
4110 "buyer_company".into(),
4111 Value::String(pair.buyer_company.clone()),
4112 );
4113 let amt: f64 = pair.amount.to_string().parse().unwrap_or(0.0);
4114 p.insert("amount".into(), serde_json::json!(amt));
4115 p.insert("currency".into(), Value::String(pair.currency.clone()));
4116 p.insert(
4117 "settlement_status".into(),
4118 Value::String(format!("{:?}", pair.settlement_status)),
4119 );
4120 p.insert(
4121 "transaction_date".into(),
4122 Value::String(pair.transaction_date.to_string()),
4123 );
4124 p
4125 },
4126 features: vec![pair
4127 .amount
4128 .to_string()
4129 .parse::<f64>()
4130 .unwrap_or(0.0)
4131 .abs()
4132 .ln_1p()],
4133 is_anomaly: false,
4134 anomaly_type: None,
4135 is_aggregate: false,
4136 aggregate_count: 0,
4137 });
4138 }
4139
4140 for elim in elimination_entries {
4141 let node_id = format!("ic_elim_{}", elim.entry_id);
4142 self.try_add_node(HypergraphNode {
4143 id: node_id,
4144 entity_type: "elimination_entry".into(),
4145 entity_type_code: type_codes::ELIMINATION_ENTRY,
4146 layer: HypergraphLayer::AccountingNetwork,
4147 external_id: elim.entry_id.clone(),
4148 label: format!(
4149 "ELIM {:?} {} {}",
4150 elim.elimination_type, elim.consolidation_entity, elim.fiscal_period
4151 ),
4152 properties: {
4153 let mut p = HashMap::new();
4154 p.insert(
4155 "elimination_type".into(),
4156 Value::String(format!("{:?}", elim.elimination_type)),
4157 );
4158 p.insert(
4159 "consolidation_entity".into(),
4160 Value::String(elim.consolidation_entity.clone()),
4161 );
4162 p.insert(
4163 "fiscal_period".into(),
4164 Value::String(elim.fiscal_period.clone()),
4165 );
4166 p.insert("currency".into(), Value::String(elim.currency.clone()));
4167 p.insert("is_permanent".into(), Value::Bool(elim.is_permanent));
4168 let debit: f64 = elim.total_debit.to_string().parse().unwrap_or(0.0);
4169 p.insert("total_debit".into(), serde_json::json!(debit));
4170 p
4171 },
4172 features: vec![elim
4173 .total_debit
4174 .to_string()
4175 .parse::<f64>()
4176 .unwrap_or(0.0)
4177 .abs()
4178 .ln_1p()],
4179 is_anomaly: false,
4180 anomaly_type: None,
4181 is_aggregate: false,
4182 aggregate_count: 0,
4183 });
4184 }
4185 }
4186
4187 pub fn add_temporal_events(
4192 &mut self,
4193 process_events: &[ProcessEvolutionEvent],
4194 organizational_events: &[OrganizationalEvent],
4195 disruption_events: &[DisruptionEvent],
4196 ) {
4197 if !self.config.include_temporal_events {
4198 return;
4199 }
4200
4201 for pe in process_events {
4202 let node_id = format!("tevt_proc_{}", pe.event_id);
4203 self.try_add_node(HypergraphNode {
4204 id: node_id,
4205 entity_type: "process_evolution".into(),
4206 entity_type_code: type_codes::PROCESS_EVOLUTION,
4207 layer: HypergraphLayer::ProcessEvents,
4208 external_id: pe.event_id.clone(),
4209 label: format!("PEVOL {} {}", pe.event_id, pe.effective_date),
4210 properties: {
4211 let mut p = HashMap::new();
4212 p.insert(
4213 "event_type".into(),
4214 Value::String(format!("{:?}", pe.event_type)),
4215 );
4216 p.insert(
4217 "effective_date".into(),
4218 Value::String(pe.effective_date.to_string()),
4219 );
4220 if let Some(ref desc) = pe.description {
4221 p.insert("description".into(), Value::String(desc.clone()));
4222 }
4223 if !pe.tags.is_empty() {
4224 p.insert(
4225 "tags".into(),
4226 Value::Array(
4227 pe.tags.iter().map(|t| Value::String(t.clone())).collect(),
4228 ),
4229 );
4230 }
4231 p
4232 },
4233 features: vec![],
4234 is_anomaly: false,
4235 anomaly_type: None,
4236 is_aggregate: false,
4237 aggregate_count: 0,
4238 });
4239 }
4240
4241 for oe in organizational_events {
4242 let node_id = format!("tevt_org_{}", oe.event_id);
4243 self.try_add_node(HypergraphNode {
4244 id: node_id,
4245 entity_type: "organizational_event".into(),
4246 entity_type_code: type_codes::ORGANIZATIONAL_EVENT,
4247 layer: HypergraphLayer::ProcessEvents,
4248 external_id: oe.event_id.clone(),
4249 label: format!("ORGEV {} {}", oe.event_id, oe.effective_date),
4250 properties: {
4251 let mut p = HashMap::new();
4252 p.insert(
4253 "event_type".into(),
4254 Value::String(format!("{:?}", oe.event_type)),
4255 );
4256 p.insert(
4257 "effective_date".into(),
4258 Value::String(oe.effective_date.to_string()),
4259 );
4260 if let Some(ref desc) = oe.description {
4261 p.insert("description".into(), Value::String(desc.clone()));
4262 }
4263 if !oe.tags.is_empty() {
4264 p.insert(
4265 "tags".into(),
4266 Value::Array(
4267 oe.tags.iter().map(|t| Value::String(t.clone())).collect(),
4268 ),
4269 );
4270 }
4271 p
4272 },
4273 features: vec![],
4274 is_anomaly: false,
4275 anomaly_type: None,
4276 is_aggregate: false,
4277 aggregate_count: 0,
4278 });
4279 }
4280
4281 for de in disruption_events {
4282 let node_id = format!("tevt_dis_{}", de.event_id);
4283 self.try_add_node(HypergraphNode {
4284 id: node_id,
4285 entity_type: "disruption_event".into(),
4286 entity_type_code: type_codes::DISRUPTION_EVENT,
4287 layer: HypergraphLayer::ProcessEvents,
4288 external_id: de.event_id.clone(),
4289 label: format!("DISRUPT {} sev={}", de.event_id, de.severity),
4290 properties: {
4291 let mut p = HashMap::new();
4292 p.insert(
4293 "disruption_type".into(),
4294 Value::String(format!("{:?}", de.disruption_type)),
4295 );
4296 p.insert("description".into(), Value::String(de.description.clone()));
4297 p.insert("severity".into(), Value::Number(de.severity.into()));
4298 if !de.affected_companies.is_empty() {
4299 p.insert(
4300 "affected_companies".into(),
4301 Value::Array(
4302 de.affected_companies
4303 .iter()
4304 .map(|c| Value::String(c.clone()))
4305 .collect(),
4306 ),
4307 );
4308 }
4309 p
4310 },
4311 features: vec![de.severity as f64 / 5.0],
4312 is_anomaly: de.severity >= 4,
4313 anomaly_type: if de.severity >= 4 {
4314 Some("high_severity_disruption".into())
4315 } else {
4316 None
4317 },
4318 is_aggregate: false,
4319 aggregate_count: 0,
4320 });
4321 }
4322 }
4323
4324 pub fn add_aml_alerts(&mut self, transactions: &[BankTransaction]) {
4329 let suspicious: Vec<&BankTransaction> =
4330 transactions.iter().filter(|t| t.is_suspicious).collect();
4331
4332 for txn in suspicious {
4333 let tid = txn.transaction_id.to_string();
4334 let node_id = format!("aml_alert_{tid}");
4335 self.try_add_node(HypergraphNode {
4336 id: node_id,
4337 entity_type: "aml_alert".into(),
4338 entity_type_code: type_codes::AML_ALERT,
4339 layer: HypergraphLayer::ProcessEvents,
4340 external_id: format!("AML-{tid}"),
4341 label: format!("AML {}", txn.reference),
4342 properties: {
4343 let mut p = HashMap::new();
4344 p.insert("transaction_id".into(), Value::String(tid.clone()));
4345 let amount: f64 = txn.amount.to_string().parse().unwrap_or(0.0);
4346 p.insert("amount".into(), serde_json::json!(amount));
4347 p.insert("currency".into(), Value::String(txn.currency.clone()));
4348 p.insert("reference".into(), Value::String(txn.reference.clone()));
4349 if let Some(ref reason) = txn.suspicion_reason {
4350 p.insert(
4351 "suspicion_reason".into(),
4352 Value::String(format!("{reason:?}")),
4353 );
4354 }
4355 if let Some(ref stage) = txn.laundering_stage {
4356 p.insert(
4357 "laundering_stage".into(),
4358 Value::String(format!("{stage:?}")),
4359 );
4360 }
4361 p
4362 },
4363 features: vec![txn
4364 .amount
4365 .to_string()
4366 .parse::<f64>()
4367 .unwrap_or(0.0)
4368 .abs()
4369 .ln_1p()],
4370 is_anomaly: true,
4371 anomaly_type: txn.suspicion_reason.as_ref().map(|r| format!("{r:?}")),
4372 is_aggregate: false,
4373 aggregate_count: 0,
4374 });
4375 }
4376 }
4377
4378 pub fn add_kyc_profiles(&mut self, customers: &[BankingCustomer]) {
4383 for cust in customers {
4384 let cid = cust.customer_id.to_string();
4385 let node_id = format!("kyc_{cid}");
4386 self.try_add_node(HypergraphNode {
4387 id: node_id,
4388 entity_type: "kyc_profile".into(),
4389 entity_type_code: type_codes::KYC_PROFILE,
4390 layer: HypergraphLayer::ProcessEvents,
4391 external_id: format!("KYC-{cid}"),
4392 label: format!("KYC {}", cust.name.legal_name),
4393 properties: {
4394 let mut p = HashMap::new();
4395 p.insert("customer_id".into(), Value::String(cid.clone()));
4396 p.insert("name".into(), Value::String(cust.name.legal_name.clone()));
4397 p.insert(
4398 "customer_type".into(),
4399 Value::String(format!("{:?}", cust.customer_type)),
4400 );
4401 p.insert(
4402 "risk_tier".into(),
4403 Value::String(format!("{:?}", cust.risk_tier)),
4404 );
4405 p.insert(
4406 "residence_country".into(),
4407 Value::String(cust.residence_country.clone()),
4408 );
4409 p.insert("is_pep".into(), Value::Bool(cust.is_pep));
4410 p.insert("is_mule".into(), Value::Bool(cust.is_mule));
4411 p
4412 },
4413 features: vec![
4414 if cust.is_pep { 1.0 } else { 0.0 },
4415 if cust.is_mule { 1.0 } else { 0.0 },
4416 ],
4417 is_anomaly: cust.is_mule,
4418 anomaly_type: if cust.is_mule {
4419 Some("mule_account".into())
4420 } else {
4421 None
4422 },
4423 is_aggregate: false,
4424 aggregate_count: 0,
4425 });
4426 }
4427 }
4428
4429 pub fn tag_process_family(&mut self) {
4434 for node in &mut self.nodes {
4435 let family = match node.entity_type.as_str() {
4436 "purchase_order" | "goods_receipt" | "vendor_invoice" | "payment" | "p2p_pool" => {
4438 "P2P"
4439 }
4440 "sales_order" | "delivery" | "customer_invoice" | "o2c_pool" => "O2C",
4442 "sourcing_project"
4444 | "supplier_qualification"
4445 | "rfx_event"
4446 | "supplier_bid"
4447 | "bid_evaluation"
4448 | "procurement_contract" => "S2C",
4449 "payroll_run" | "time_entry" | "expense_report" | "payroll_line_item" => "H2R",
4451 "production_order" | "quality_inspection" | "cycle_count" => "MFG",
4453 "banking_customer" | "bank_account" | "bank_transaction" | "aml_alert"
4455 | "kyc_profile" => "BANK",
4456 "audit_engagement"
4458 | "workpaper"
4459 | "audit_finding"
4460 | "audit_evidence"
4461 | "risk_assessment"
4462 | "professional_judgment" => "AUDIT",
4463 "bank_reconciliation" | "bank_statement_line" | "reconciling_item" => "R2R",
4465 "tax_jurisdiction"
4467 | "tax_code"
4468 | "tax_line"
4469 | "tax_return"
4470 | "tax_provision"
4471 | "withholding_tax_record" => "TAX",
4472 "cash_position" | "cash_forecast" | "hedge_relationship" | "debt_instrument" => {
4474 "TREASURY"
4475 }
4476 "emission_record"
4478 | "esg_disclosure"
4479 | "supplier_esg_assessment"
4480 | "climate_scenario" => "ESG",
4481 "project" | "earned_value_metric" | "project_milestone" => "PROJECT",
4483 "ic_matched_pair" | "elimination_entry" => "IC",
4485 "process_evolution" | "organizational_event" | "disruption_event" => "TEMPORAL",
4487 "compliance_standard" | "compliance_finding" | "regulatory_filing" => "COMPLIANCE",
4489 "coso_component" | "coso_principle" | "sox_assertion" | "internal_control" => {
4491 "GOVERNANCE"
4492 }
4493 "vendor" | "customer" | "employee" | "material" | "fixed_asset" => "MASTER_DATA",
4495 "account" | "journal_entry" => "ACCOUNTING",
4497 "ocpm_object" => "OCPM",
4499 _ => "OTHER",
4501 };
4502 node.properties
4503 .insert("process_family".into(), Value::String(family.to_string()));
4504 }
4505 }
4506
4507 pub fn build_cross_layer_edges(&mut self) {
4509 if !self.config.include_cross_layer_edges {
4510 return;
4511 }
4512
4513 let links = std::mem::take(&mut self.doc_counterparty_links);
4515 for (doc_node_id, counterparty_type, counterparty_id) in &links {
4516 let source_node_id = match counterparty_type.as_str() {
4517 "vendor" => self.vendor_node_ids.get(counterparty_id),
4518 "customer" => self.customer_node_ids.get(counterparty_id),
4519 _ => None,
4520 };
4521 if let Some(source_id) = source_node_id {
4522 self.edges.push(CrossLayerEdge {
4523 source_id: source_id.clone(),
4524 source_layer: HypergraphLayer::GovernanceControls,
4525 target_id: doc_node_id.clone(),
4526 target_layer: HypergraphLayer::ProcessEvents,
4527 edge_type: "SuppliesTo".to_string(),
4528 edge_type_code: type_codes::SUPPLIES_TO,
4529 properties: HashMap::new(),
4530 });
4531 }
4532 }
4533 self.doc_counterparty_links = links;
4534
4535 let finding_ctrl_links = std::mem::take(&mut self.compliance_finding_control_links);
4537 for (finding_node_id, ctrl_id) in &finding_ctrl_links {
4538 if let Some(ctrl_node_id) = self.control_node_ids.get(ctrl_id) {
4539 self.edges.push(CrossLayerEdge {
4540 source_id: finding_node_id.clone(),
4541 source_layer: HypergraphLayer::ProcessEvents,
4542 target_id: ctrl_node_id.clone(),
4543 target_layer: HypergraphLayer::GovernanceControls,
4544 edge_type: "FindingOnControl".to_string(),
4545 edge_type_code: type_codes::FINDING_ON_CONTROL,
4546 properties: HashMap::new(),
4547 });
4548 }
4549 }
4550 self.compliance_finding_control_links = finding_ctrl_links;
4551
4552 let std_ids: Vec<(String, String)> = self
4554 .standard_node_ids
4555 .iter()
4556 .map(|(k, v)| (k.clone(), v.clone()))
4557 .collect();
4558 for (std_id, std_node_id) in &std_ids {
4559 if let Some(&node_idx) = self.node_index.get(std_node_id) {
4561 if let Some(node) = self.nodes.get(node_idx) {
4562 if let Some(Value::Array(acct_types)) =
4563 node.properties.get("applicableAccountTypes")
4564 {
4565 let type_strings: Vec<String> = acct_types
4566 .iter()
4567 .filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
4568 .collect();
4569
4570 for (acct_code, acct_node_id) in &self.account_node_ids {
4572 if let Some(&acct_idx) = self.node_index.get(acct_node_id) {
4574 if let Some(acct_node) = self.nodes.get(acct_idx) {
4575 let label_lower = acct_node.label.to_lowercase();
4576 let matches = type_strings.iter().any(|t| {
4577 label_lower.contains(t)
4578 || acct_code.to_lowercase().contains(t)
4579 });
4580 if matches {
4581 self.edges.push(CrossLayerEdge {
4582 source_id: std_node_id.clone(),
4583 source_layer: HypergraphLayer::GovernanceControls,
4584 target_id: acct_node_id.clone(),
4585 target_layer: HypergraphLayer::AccountingNetwork,
4586 edge_type: format!("GovernedByStandard:{}", std_id),
4587 edge_type_code: type_codes::STANDARD_TO_ACCOUNT,
4588 properties: HashMap::new(),
4589 });
4590 }
4591 }
4592 }
4593 }
4594 }
4595 }
4596 }
4597 }
4598
4599 for (_std_id, std_node_id) in &std_ids {
4601 if let Some(&node_idx) = self.node_index.get(std_node_id) {
4602 if let Some(node) = self.nodes.get(node_idx) {
4603 if let Some(Value::Array(processes)) =
4604 node.properties.get("applicableProcesses")
4605 {
4606 let proc_strings: Vec<String> = processes
4607 .iter()
4608 .filter_map(|v| v.as_str().map(|s| s.to_string()))
4609 .collect();
4610
4611 let is_universal = proc_strings.len() >= 5;
4613 if is_universal {
4614 for ctrl_node_id in self.control_node_ids.values() {
4616 self.edges.push(CrossLayerEdge {
4617 source_id: std_node_id.clone(),
4618 source_layer: HypergraphLayer::GovernanceControls,
4619 target_id: ctrl_node_id.clone(),
4620 target_layer: HypergraphLayer::GovernanceControls,
4621 edge_type: "StandardToControl".to_string(),
4622 edge_type_code: type_codes::STANDARD_TO_CONTROL,
4623 properties: HashMap::new(),
4624 });
4625 }
4626 }
4627 }
4628 }
4629 }
4630 }
4631 }
4632
4633 pub fn build(mut self) -> Hypergraph {
4635 self.build_cross_layer_edges();
4637
4638 let mut layer_node_counts: HashMap<String, usize> = HashMap::new();
4640 let mut node_type_counts: HashMap<String, usize> = HashMap::new();
4641 let mut anomalous_nodes = 0;
4642
4643 for node in &self.nodes {
4644 *layer_node_counts
4645 .entry(node.layer.name().to_string())
4646 .or_insert(0) += 1;
4647 *node_type_counts
4648 .entry(node.entity_type.clone())
4649 .or_insert(0) += 1;
4650 if node.is_anomaly {
4651 anomalous_nodes += 1;
4652 }
4653 }
4654
4655 let mut edge_type_counts: HashMap<String, usize> = HashMap::new();
4656 for edge in &self.edges {
4657 *edge_type_counts.entry(edge.edge_type.clone()).or_insert(0) += 1;
4658 }
4659
4660 let mut hyperedge_type_counts: HashMap<String, usize> = HashMap::new();
4661 let mut anomalous_hyperedges = 0;
4662 for he in &self.hyperedges {
4663 *hyperedge_type_counts
4664 .entry(he.hyperedge_type.clone())
4665 .or_insert(0) += 1;
4666 if he.is_anomaly {
4667 anomalous_hyperedges += 1;
4668 }
4669 }
4670
4671 let budget_report = NodeBudgetReport {
4672 total_budget: self.budget.total_max(),
4673 total_used: self.budget.total_count(),
4674 layer1_budget: self.budget.layer1_max,
4675 layer1_used: self.budget.layer1_count,
4676 layer2_budget: self.budget.layer2_max,
4677 layer2_used: self.budget.layer2_count,
4678 layer3_budget: self.budget.layer3_max,
4679 layer3_used: self.budget.layer3_count,
4680 aggregate_nodes_created: self.aggregate_count,
4681 aggregation_triggered: self.aggregate_count > 0,
4682 };
4683
4684 let metadata = HypergraphMetadata {
4685 name: "multi_layer_hypergraph".to_string(),
4686 num_nodes: self.nodes.len(),
4687 num_edges: self.edges.len(),
4688 num_hyperedges: self.hyperedges.len(),
4689 layer_node_counts,
4690 node_type_counts,
4691 edge_type_counts,
4692 hyperedge_type_counts,
4693 anomalous_nodes,
4694 anomalous_hyperedges,
4695 source: "datasynth".to_string(),
4696 generated_at: chrono::Utc::now().to_rfc3339(),
4697 budget_report: budget_report.clone(),
4698 files: vec![
4699 "nodes.jsonl".to_string(),
4700 "edges.jsonl".to_string(),
4701 "hyperedges.jsonl".to_string(),
4702 "metadata.json".to_string(),
4703 ],
4704 };
4705
4706 Hypergraph {
4707 nodes: self.nodes,
4708 edges: self.edges,
4709 hyperedges: self.hyperedges,
4710 metadata,
4711 budget_report,
4712 }
4713 }
4714
4715 fn try_add_node(&mut self, node: HypergraphNode) -> bool {
4717 if self.node_index.contains_key(&node.id) {
4718 return false; }
4720
4721 if !self.budget.can_add(node.layer) {
4722 return false; }
4724
4725 let id = node.id.clone();
4726 let layer = node.layer;
4727 self.nodes.push(node);
4728 let idx = self.nodes.len() - 1;
4729 self.node_index.insert(id, idx);
4730 self.budget.record_add(layer);
4731 true
4732 }
4733}
4734
4735fn component_to_feature(component: &CosoComponent) -> f64 {
4737 match component {
4738 CosoComponent::ControlEnvironment => 1.0,
4739 CosoComponent::RiskAssessment => 2.0,
4740 CosoComponent::ControlActivities => 3.0,
4741 CosoComponent::InformationCommunication => 4.0,
4742 CosoComponent::MonitoringActivities => 5.0,
4743 }
4744}
4745
4746fn account_type_feature(account_type: &datasynth_core::models::AccountType) -> f64 {
4748 use datasynth_core::models::AccountType;
4749 match account_type {
4750 AccountType::Asset => 1.0,
4751 AccountType::Liability => 2.0,
4752 AccountType::Equity => 3.0,
4753 AccountType::Revenue => 4.0,
4754 AccountType::Expense => 5.0,
4755 AccountType::Statistical => 6.0,
4756 }
4757}
4758
4759fn compute_je_features(entry: &JournalEntry) -> Vec<f64> {
4761 let total_debit: f64 = entry
4762 .lines
4763 .iter()
4764 .map(|l| l.debit_amount.to_string().parse::<f64>().unwrap_or(0.0))
4765 .sum();
4766
4767 let line_count = entry.lines.len() as f64;
4768 let posting_date = entry.header.posting_date;
4769 let weekday = posting_date.weekday().num_days_from_monday() as f64 / WEEKDAY_NORMALIZER;
4770 let day = posting_date.day() as f64 / DAY_OF_MONTH_NORMALIZER;
4771 let month = posting_date.month() as f64 / MONTH_NORMALIZER;
4772 let is_month_end = if posting_date.day() >= MONTH_END_DAY_THRESHOLD {
4773 1.0
4774 } else {
4775 0.0
4776 };
4777
4778 vec![
4779 (total_debit.abs() + 1.0).ln(), line_count, weekday, day, month, is_month_end, ]
4786}
4787
4788#[cfg(test)]
4789#[allow(clippy::unwrap_used)]
4790mod tests {
4791 use super::*;
4792 use datasynth_core::models::{
4793 AccountSubType, AccountType, ChartOfAccounts, CoAComplexity, ControlFrequency, ControlType,
4794 CosoComponent, CosoMaturityLevel, GLAccount, InternalControl, RiskLevel, SoxAssertion,
4795 UserPersona,
4796 };
4797
4798 fn make_test_coa() -> ChartOfAccounts {
4799 let mut coa = ChartOfAccounts::new(
4800 "TEST_COA".to_string(),
4801 "Test Chart".to_string(),
4802 "US".to_string(),
4803 datasynth_core::models::IndustrySector::Manufacturing,
4804 CoAComplexity::Small,
4805 );
4806
4807 coa.add_account(GLAccount::new(
4808 "1000".to_string(),
4809 "Cash".to_string(),
4810 AccountType::Asset,
4811 AccountSubType::Cash,
4812 ));
4813 coa.add_account(GLAccount::new(
4814 "2000".to_string(),
4815 "AP".to_string(),
4816 AccountType::Liability,
4817 AccountSubType::AccountsPayable,
4818 ));
4819
4820 coa
4821 }
4822
4823 fn make_test_control() -> InternalControl {
4824 InternalControl {
4825 control_id: "C001".to_string(),
4826 control_name: "Three-Way Match".to_string(),
4827 control_type: ControlType::Preventive,
4828 objective: "Ensure proper matching".to_string(),
4829 frequency: ControlFrequency::Transactional,
4830 owner_role: UserPersona::Controller,
4831 risk_level: RiskLevel::High,
4832 description: "Test control".to_string(),
4833 is_key_control: true,
4834 sox_assertion: SoxAssertion::Existence,
4835 coso_component: CosoComponent::ControlActivities,
4836 coso_principles: vec![CosoPrinciple::ControlActions],
4837 control_scope: datasynth_core::models::ControlScope::TransactionLevel,
4838 maturity_level: CosoMaturityLevel::Managed,
4839 owner_employee_id: None,
4840 owner_name: "Test Controller".to_string(),
4841 test_count: 0,
4842 last_tested_date: None,
4843 test_result: datasynth_core::models::internal_control::TestResult::default(),
4844 effectiveness: datasynth_core::models::internal_control::ControlEffectiveness::default(
4845 ),
4846 mitigates_risk_ids: Vec::new(),
4847 covers_account_classes: Vec::new(),
4848 }
4849 }
4850
4851 #[test]
4852 fn test_builder_coso_framework() {
4853 let config = HypergraphConfig {
4854 max_nodes: 1000,
4855 ..Default::default()
4856 };
4857 let mut builder = HypergraphBuilder::new(config);
4858 builder.add_coso_framework();
4859
4860 let hg = builder.build();
4861 assert_eq!(hg.nodes.len(), 22);
4863 assert!(hg
4864 .nodes
4865 .iter()
4866 .all(|n| n.layer == HypergraphLayer::GovernanceControls));
4867 assert_eq!(
4869 hg.edges
4870 .iter()
4871 .filter(|e| e.edge_type == "CoversCosoPrinciple")
4872 .count(),
4873 17
4874 );
4875 }
4876
4877 #[test]
4878 fn test_builder_controls() {
4879 let config = HypergraphConfig {
4880 max_nodes: 1000,
4881 ..Default::default()
4882 };
4883 let mut builder = HypergraphBuilder::new(config);
4884 builder.add_coso_framework();
4885 builder.add_controls(&[make_test_control()]);
4886
4887 let hg = builder.build();
4888 assert_eq!(hg.nodes.len(), 24);
4890 assert!(hg.nodes.iter().any(|n| n.entity_type == "internal_control"));
4891 assert!(hg.nodes.iter().any(|n| n.entity_type == "sox_assertion"));
4892 }
4893
4894 #[test]
4895 fn test_builder_accounts() {
4896 let config = HypergraphConfig {
4897 max_nodes: 1000,
4898 ..Default::default()
4899 };
4900 let mut builder = HypergraphBuilder::new(config);
4901 builder.add_accounts(&make_test_coa());
4902
4903 let hg = builder.build();
4904 assert_eq!(hg.nodes.len(), 2);
4905 assert!(hg
4906 .nodes
4907 .iter()
4908 .all(|n| n.layer == HypergraphLayer::AccountingNetwork));
4909 }
4910
4911 #[test]
4912 fn test_budget_enforcement() {
4913 let config = HypergraphConfig {
4914 max_nodes: 10, include_coso: false,
4916 include_controls: false,
4917 include_sox: false,
4918 include_vendors: false,
4919 include_customers: false,
4920 include_employees: false,
4921 include_p2p: false,
4922 include_o2c: false,
4923 ..Default::default()
4924 };
4925 let mut builder = HypergraphBuilder::new(config);
4926 builder.add_accounts(&make_test_coa());
4927
4928 let hg = builder.build();
4929 assert!(hg.nodes.len() <= 1);
4931 }
4932
4933 #[test]
4934 fn test_full_build() {
4935 let config = HypergraphConfig {
4936 max_nodes: 10000,
4937 ..Default::default()
4938 };
4939 let mut builder = HypergraphBuilder::new(config);
4940 builder.add_coso_framework();
4941 builder.add_controls(&[make_test_control()]);
4942 builder.add_accounts(&make_test_coa());
4943
4944 let hg = builder.build();
4945 assert!(!hg.nodes.is_empty());
4946 assert!(!hg.edges.is_empty());
4947 assert_eq!(hg.metadata.num_nodes, hg.nodes.len());
4948 assert_eq!(hg.metadata.num_edges, hg.edges.len());
4949 }
4950}