Skip to main content

datasynth_graph/builders/
hypergraph.rs

1//! Multi-layer hypergraph builder for RustGraph integration.
2//!
3//! Constructs a 3-layer hypergraph from accounting data:
4//! - Layer 1: Governance & Controls (COSO, internal controls, master data)
5//! - Layer 2: Process Events (P2P/O2C documents, OCPM events)
6//! - Layer 3: Accounting Network (GL accounts, journal entries as hyperedges)
7//!
8//! Includes a node budget system that allocates capacity across layers and
9//! aggregates overflow nodes into pool nodes when budget is exceeded.
10
11use 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
46/// Day-of-month threshold for considering a date as "month-end" in features.
47const MONTH_END_DAY_THRESHOLD: u32 = 28;
48/// Normalizer for weekday feature (0=Monday..6=Sunday).
49const WEEKDAY_NORMALIZER: f64 = 6.0;
50/// Normalizer for day-of-month feature.
51const DAY_OF_MONTH_NORMALIZER: f64 = 31.0;
52/// Normalizer for month feature.
53const MONTH_NORMALIZER: f64 = 12.0;
54
55/// RustGraph entity type codes — canonical codes from AssureTwin's entity_registry.rs.
56/// Not all codes are consumed yet; the full set is kept for parity with the
57/// upstream registry so that new layer builders can reference them immediately.
58#[allow(dead_code)]
59mod type_codes {
60    // Layer 3 — Accounting / Master Data
61    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    // People / Organizations
68    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    // Layer 2 process type codes — P2P
74    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    // Layer 2 — O2C
79    pub const SALES_ORDER: u32 = 310;
80    pub const DELIVERY: u32 = 311;
81    pub const CUSTOMER_INVOICE: u32 = 312;
82    // Layer 2 — S2C
83    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    // Layer 2 — H2R
90    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    // Layer 2 — MFG
95    pub const PRODUCTION_ORDER: u32 = 340;
96    pub const QUALITY_INSPECTION: u32 = 341;
97    pub const CYCLE_COUNT: u32 = 342;
98    // Layer 2 — BANK
99    pub const BANK_ACCOUNT: u32 = 350;
100    pub const BANK_TRANSACTION: u32 = 351;
101    pub const BANK_STATEMENT_LINE: u32 = 352;
102    // Layer 2 — AUDIT
103    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    // Layer 2 — Bank Recon (R2R subfamily)
110    pub const BANK_RECONCILIATION: u32 = 370;
111    pub const RECONCILING_ITEM: u32 = 372;
112    // Layer 2 — OCPM events
113    pub const OCPM_EVENT: u32 = 400;
114    // Pool / aggregate
115    pub const POOL_NODE: u32 = 399;
116
117    // Layer 1 — Governance
118    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    // Layer 2 — Compliance events
126    pub const REGULATORY_FILING: u32 = 507;
127    pub const COMPLIANCE_FINDING: u32 = 508;
128
129    // Layer 3 — Tax
130    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    // Layer 3 — Treasury
138    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    // Layer 1 — ESG
144    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    // Layer 3 — Project Accounting
150    pub const PROJECT: u32 = 451;
151    pub const EARNED_VALUE: u32 = 452;
152    pub const PROJECT_MILESTONE: u32 = 454;
153
154    // Layer 3 — Intercompany
155    pub const IC_MATCHED_PAIR: u32 = 460;
156    pub const ELIMINATION_ENTRY: u32 = 461;
157
158    // Layer 2 — Temporal Events
159    pub const PROCESS_EVOLUTION: u32 = 470;
160    pub const ORGANIZATIONAL_EVENT: u32 = 471;
161    pub const DISRUPTION_EVENT: u32 = 472;
162
163    // Layer 2 — AML/KYC (from banking)
164    pub const AML_ALERT: u32 = 505;
165    // KYC_PROFILE already defined above as 504
166
167    // Layer 1 — Audit Procedure entities
168    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    // Edge type codes
179    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    // Audit Procedure edge type codes
193    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/// Configuration for the hypergraph builder.
211#[derive(Debug, Clone)]
212pub struct HypergraphConfig {
213    /// Maximum total nodes across all layers.
214    pub max_nodes: usize,
215    /// Aggregation strategy when budget is exceeded.
216    pub aggregation_strategy: AggregationStrategy,
217    // Layer 1 toggles
218    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    // Layer 2 toggles
225    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    /// Documents per counterparty above which aggregation is triggered.
242    pub docs_per_counterparty_threshold: usize,
243    // Layer 3 toggles
244    pub include_accounts: bool,
245    pub je_as_hyperedges: bool,
246    // Cross-layer
247    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/// Per-layer demand counts for budget rebalancing.
286///
287/// Used by [`HypergraphBuilder::suggest_budget`] and
288/// [`HypergraphBuilder::rebalance_with_demand`] to redistribute unused
289/// capacity from low-demand layers to high-demand layers.
290#[derive(Debug, Clone, Default)]
291pub struct LayerDemand {
292    /// Number of L1 (Governance) entities expected.
293    pub l1: usize,
294    /// Number of L2 (Process) entities expected.
295    pub l2: usize,
296    /// Number of L3 (Accounting) entities expected.
297    pub l3: usize,
298}
299
300/// Input data for [`HypergraphBuilder::add_all_ordered`].
301///
302/// Groups all entity slices by phase so the builder can enforce the correct
303/// insertion order: governance first, then critical L2 entities (audit),
304/// then volume L2 entities (banking, P2P, etc.), then L3 accounting.
305///
306/// All fields are optional — pass empty slices for phases you don't need.
307#[derive(Default)]
308pub struct BuilderInput<'a> {
309    // --- Phase 1: L1 Governance ---
310    /// Internal controls (L1).
311    pub controls: &'a [InternalControl],
312    /// Vendors (L1 master data).
313    pub vendors: &'a [Vendor],
314    /// Customers (L1 master data).
315    pub customers: &'a [Customer],
316    /// Employees (L1 master data).
317    pub employees: &'a [Employee],
318    /// Materials (L1/L3 master data).
319    pub materials: &'a [Material],
320    /// Fixed assets (L1/L3 master data).
321    pub fixed_assets: &'a [FixedAsset],
322    /// Compliance standards (L1 governance).
323    pub compliance_standards: &'a [ComplianceStandard],
324    /// Compliance findings (L2 events).
325    pub compliance_findings: &'a [ComplianceFinding],
326    /// Regulatory filings (L2 events).
327    pub regulatory_filings: &'a [RegulatoryFiling],
328    /// Emission records (L1 ESG).
329    pub emissions: &'a [EmissionRecord],
330    /// ESG disclosures (L1 ESG).
331    pub esg_disclosures: &'a [EsgDisclosure],
332    /// Supplier ESG assessments (L1 ESG).
333    pub supplier_esg_assessments: &'a [SupplierEsgAssessment],
334    /// Climate scenarios (L1 ESG).
335    pub climate_scenarios: &'a [ClimateScenario],
336
337    // --- Phase 2: L2 Critical (audit — small, must not be dropped) ---
338    /// Audit engagements.
339    pub audit_engagements: &'a [AuditEngagement],
340    /// Workpapers.
341    pub workpapers: &'a [Workpaper],
342    /// Audit findings.
343    pub audit_findings: &'a [AuditFinding],
344    /// Audit evidence.
345    pub audit_evidence: &'a [AuditEvidence],
346    /// Risk assessments.
347    pub risk_assessments: &'a [RiskAssessment],
348    /// Professional judgments.
349    pub professional_judgments: &'a [ProfessionalJudgment],
350    /// External confirmation requests (ISA 505).
351    pub external_confirmations: &'a [ExternalConfirmation],
352    /// Confirmation responses (ISA 505).
353    pub confirmation_responses: &'a [ConfirmationResponse],
354    /// Audit procedure steps (ISA 330/530).
355    pub audit_procedure_steps: &'a [AuditProcedureStep],
356    /// Audit samples (ISA 530).
357    pub audit_samples: &'a [AuditSample],
358    /// Analytical procedure results (ISA 520).
359    pub analytical_procedure_results: &'a [AnalyticalProcedureResult],
360    /// Internal audit functions assessed (ISA 610).
361    pub internal_audit_functions: &'a [InternalAuditFunction],
362    /// Internal audit reports reviewed (ISA 610).
363    pub internal_audit_reports: &'a [InternalAuditReport],
364    /// Related parties identified (ISA 550).
365    pub related_parties: &'a [RelatedParty],
366    /// Related party transactions (ISA 550).
367    pub related_party_transactions: &'a [RelatedPartyTransaction],
368
369    // --- Phase 3: L2 Volume ---
370    /// P2P: Purchase orders.
371    pub purchase_orders: &'a [datasynth_core::models::documents::PurchaseOrder],
372    /// P2P: Goods receipts.
373    pub goods_receipts: &'a [datasynth_core::models::documents::GoodsReceipt],
374    /// P2P: Vendor invoices.
375    pub vendor_invoices: &'a [datasynth_core::models::documents::VendorInvoice],
376    /// P2P: Payments.
377    pub payments: &'a [datasynth_core::models::documents::Payment],
378    /// O2C: Sales orders.
379    pub sales_orders: &'a [datasynth_core::models::documents::SalesOrder],
380    /// O2C: Deliveries.
381    pub deliveries: &'a [datasynth_core::models::documents::Delivery],
382    /// O2C: Customer invoices.
383    pub customer_invoices: &'a [datasynth_core::models::documents::CustomerInvoice],
384    /// S2C: Sourcing projects.
385    pub sourcing_projects: &'a [SourcingProject],
386    /// S2C: Supplier qualifications.
387    pub supplier_qualifications: &'a [SupplierQualification],
388    /// S2C: RFx events.
389    pub rfx_events: &'a [RfxEvent],
390    /// S2C: Supplier bids.
391    pub supplier_bids: &'a [SupplierBid],
392    /// S2C: Bid evaluations.
393    pub bid_evaluations: &'a [BidEvaluation],
394    /// S2C: Procurement contracts.
395    pub procurement_contracts: &'a [ProcurementContract],
396    /// H2R: Payroll runs.
397    pub payroll_runs: &'a [PayrollRun],
398    /// H2R: Time entries.
399    pub time_entries: &'a [TimeEntry],
400    /// H2R: Expense reports.
401    pub expense_reports: &'a [ExpenseReport],
402    /// MFG: Production orders.
403    pub production_orders: &'a [ProductionOrder],
404    /// MFG: Quality inspections.
405    pub quality_inspections: &'a [QualityInspection],
406    /// MFG: Cycle counts.
407    pub cycle_counts: &'a [CycleCount],
408    /// Banking customers.
409    pub banking_customers: &'a [BankingCustomer],
410    /// Bank accounts.
411    pub bank_accounts: &'a [BankAccount],
412    /// Bank transactions.
413    pub bank_transactions: &'a [BankTransaction],
414    /// Bank reconciliations.
415    pub bank_reconciliations: &'a [BankReconciliation],
416    /// Temporal: process evolution events.
417    pub process_evolution_events: &'a [ProcessEvolutionEvent],
418    /// Temporal: organizational events.
419    pub organizational_events: &'a [OrganizationalEvent],
420    /// Temporal: disruption events.
421    pub disruption_events: &'a [DisruptionEvent],
422    /// Intercompany matched pairs.
423    pub ic_matched_pairs: &'a [ICMatchedPair],
424    /// Intercompany elimination entries.
425    pub elimination_entries: &'a [EliminationEntry],
426    /// OCPM event log (optional).
427    pub ocpm_event_log: Option<&'a datasynth_ocpm::OcpmEventLog>,
428
429    // --- Phase 4: L3 Accounting ---
430    /// Chart of accounts.
431    pub chart_of_accounts: Option<&'a ChartOfAccounts>,
432    /// Journal entries.
433    pub journal_entries: &'a [JournalEntry],
434
435    // --- Phase 5: L3 domain extensions ---
436    /// Tax jurisdictions.
437    pub tax_jurisdictions: &'a [TaxJurisdiction],
438    /// Tax codes.
439    pub tax_codes: &'a [TaxCode],
440    /// Tax lines.
441    pub tax_lines: &'a [TaxLine],
442    /// Tax returns.
443    pub tax_returns: &'a [TaxReturn],
444    /// Tax provisions.
445    pub tax_provisions: &'a [TaxProvision],
446    /// Withholding tax records.
447    pub withholding_records: &'a [WithholdingTaxRecord],
448    /// Treasury: cash positions.
449    pub cash_positions: &'a [CashPosition],
450    /// Treasury: cash forecasts.
451    pub cash_forecasts: &'a [CashForecast],
452    /// Treasury: hedge relationships.
453    pub hedge_relationships: &'a [HedgeRelationship],
454    /// Treasury: debt instruments.
455    pub debt_instruments: &'a [DebtInstrument],
456    /// Project accounting: projects.
457    pub projects: &'a [Project],
458    /// Project accounting: earned value metrics.
459    pub earned_value_metrics: &'a [EarnedValueMetric],
460    /// Project accounting: milestones.
461    pub project_milestones: &'a [ProjectMilestone],
462}
463
464/// Builder for constructing a multi-layer hypergraph.
465pub struct HypergraphBuilder {
466    config: HypergraphConfig,
467    budget: NodeBudget,
468    nodes: Vec<HypergraphNode>,
469    edges: Vec<CrossLayerEdge>,
470    hyperedges: Vec<Hyperedge>,
471    /// Track node IDs to avoid duplicates: external_id → index in nodes vec.
472    node_index: HashMap<String, usize>,
473    /// Track aggregate node count.
474    aggregate_count: usize,
475    /// Control ID → node ID mapping for cross-layer edges.
476    control_node_ids: HashMap<String, String>,
477    /// COSO component → node ID mapping.
478    coso_component_ids: HashMap<String, String>,
479    /// Account code → node ID mapping.
480    account_node_ids: HashMap<String, String>,
481    /// Vendor ID → node ID mapping.
482    vendor_node_ids: HashMap<String, String>,
483    /// Customer ID → node ID mapping.
484    customer_node_ids: HashMap<String, String>,
485    /// Employee ID → node ID mapping.
486    employee_node_ids: HashMap<String, String>,
487    /// Process document node IDs to their counterparty type and ID.
488    /// (node_id, entity_type) → counterparty_id
489    doc_counterparty_links: Vec<(String, String, String)>, // (doc_node_id, counterparty_type, counterparty_id)
490    /// Compliance standard ID → node ID mapping.
491    standard_node_ids: HashMap<String, String>,
492    /// Compliance finding → control_id deferred edges.
493    compliance_finding_control_links: Vec<(String, String)>, // (finding_node_id, control_id)
494    /// Standard → account code deferred edges (resolved in `build_cross_layer_edges`).
495    #[allow(dead_code)]
496    standard_account_links: Vec<(String, String)>, // (standard_node_id, account_code)
497}
498
499impl HypergraphBuilder {
500    /// Create a new builder with the given configuration.
501    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    /// Rebalance the per-layer budget based on actual demand.
525    /// Unused slots from layers with fewer entities than their max are
526    /// redistributed to L2 (Process), which is typically the largest consumer.
527    /// Call this after adding all governance and accounting nodes, but before
528    /// adding large L2 producers like OCPM events.
529    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    /// Compute a budget suggestion based on actual demand per layer.
534    ///
535    /// Does **not** modify the builder's budget — call [`rebalance_with_demand`]
536    /// to actually apply the suggestion.
537    pub fn suggest_budget(&self, demand: &LayerDemand) -> NodeBudgetSuggestion {
538        self.budget.suggest(demand.l1, demand.l2, demand.l3)
539    }
540
541    /// Rebalance the budget and apply the suggested allocation.
542    ///
543    /// Surplus from layers that need fewer entities than their default max is
544    /// redistributed proportionally to layers with unsatisfied demand.
545    pub fn rebalance_with_demand(&mut self, demand: &LayerDemand) {
546        self.budget.rebalance(demand.l1, demand.l2, demand.l3);
547    }
548
549    /// Return a snapshot of the current budget for inspection.
550    pub fn budget(&self) -> &NodeBudget {
551        &self.budget
552    }
553
554    /// Count entities per layer from a [`BuilderInput`] to compute demand.
555    ///
556    /// This is a convenience method that tallies the entities from each slice
557    /// according to which layer they belong to. The returned [`LayerDemand`]
558    /// can be passed to [`suggest_budget`] or [`rebalance_with_demand`].
559    pub fn count_demand(input: &BuilderInput<'_>) -> LayerDemand {
560        // COSO framework: 5 components + 17 principles = 22 fixed nodes.
561        let coso_count = 22;
562
563        // L1: Governance, controls, master data, compliance standards, ESG
564        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        // L2: Process events, audit, banking, compliance findings/filings,
578        //     H2R, MFG, S2C, temporal, intercompany, OCPM
579        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        // L3: Accounting network — accounts, journal entries, tax, treasury, project
631        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    /// Add all entities from a [`BuilderInput`] in the correct phase order.
655    ///
656    /// **Phase ordering** guarantees that critical small-count entities (like
657    /// audit documents) are inserted before large-volume producers (like banking
658    /// transactions) so they are never silently dropped by budget exhaustion.
659    ///
660    /// Phases:
661    /// 1. L1 Governance (COSO, controls, master data, compliance standards, ESG)
662    /// 2. L2 Critical (audit — small count, must not be dropped)
663    /// 3. L2 Volume (P2P, O2C, S2C, H2R, MFG, banking, temporal, IC, OCPM)
664    /// 4. L3 Accounting (chart of accounts, journal entries)
665    /// 5. L3 Domain extensions (tax, treasury, project accounting)
666    /// 6. Process family tagging
667    pub fn add_all_ordered(&mut self, input: &BuilderInput<'_>) {
668        // -- Phase 1: L1 Governance --
669        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        // -- Phase 2: L2 Critical (audit first — small, must not be dropped) --
689        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        // -- Phase 3: L2 Volume --
710        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        // -- Phase 4: L3 Accounting --
758        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        // -- Phase 5: L3 Domain extensions --
768        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        // -- Phase 6: Process family tagging --
789        self.tag_process_family();
790    }
791
792    /// Add COSO framework as Layer 1 nodes (5 components + 17 principles).
793    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                // Link principle to its parent component
941                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    /// Add internal controls as Layer 1 nodes with edges to COSO components.
958    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                // Edge: Control → COSO Component
1033                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                // Edge: Control → SOX Assertion
1047                if self.config.include_sox {
1048                    let assertion_id = format!("sox_{:?}", control.sox_assertion).to_lowercase();
1049                    // Ensure SOX assertion node exists
1050                    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    /// Add vendor master data as Layer 1 nodes.
1081    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    /// Add customer master data as Layer 1 nodes.
1118    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    /// Add employee/organizational nodes as Layer 1 nodes.
1161    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    /// Add material master data as Layer 3 nodes.
1222    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    /// Add fixed asset master data as Layer 3 nodes.
1261    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    /// Add GL accounts as Layer 3 nodes.
1303    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    /// Add journal entries as Layer 3 hyperedges.
1351    ///
1352    /// Each journal entry becomes a hyperedge connecting its debit and credit accounts.
1353    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                // Ensure account node exists (might not if CoA was incomplete)
1365                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    /// Add journal entries as standalone Layer 3 nodes.
1454    ///
1455    /// Creates a node per JE with amount, date, anomaly info, and line count.
1456    /// Use alongside `add_journal_entries_as_hyperedges` so the dashboard can
1457    /// count JE nodes while the accounting network still has proper hyperedges.
1458    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    /// Add P2P document chains as Layer 2 nodes.
1518    ///
1519    /// If a vendor has more documents than the threshold, they're aggregated into pool nodes.
1520    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        // Count documents per vendor for aggregation decisions
1532        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        // Track which vendors need pool nodes
1544        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        // Create pool nodes for high-volume vendors
1555        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        // Add individual PO nodes (if not pooled)
1587        for po in purchase_orders {
1588            if should_aggregate && vendors_needing_pools.contains(&po.vendor_id) {
1589                continue; // Pooled
1590            }
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        // Add GR nodes
1630        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        // Add vendor invoice nodes
1666        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        // Add payment nodes
1701        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    /// Add O2C document chains as Layer 2 nodes.
1722    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        // Count docs per customer for aggregation
1733        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        // Create pool nodes
1757        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    /// Add S2C (Source-to-Contract) documents as Layer 2 nodes.
1882    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            // Track vendor for cross-layer edges
2011            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    /// Add H2R (Hire-to-Retire) documents as Layer 2 nodes.
2020    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    /// Add MFG (Manufacturing) documents as Layer 2 nodes.
2093    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    /// Add Banking documents as Layer 2 nodes.
2161    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    /// Add Audit documents as Layer 2 nodes.
2317    #[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    /// Add audit procedure entities as Layer 1/2 nodes (ISA 505, 520, 530, 550, 610).
2544    ///
2545    /// Covers 9 entity types:
2546    /// - ExternalConfirmation, ConfirmationResponse (ISA 505)
2547    /// - AuditProcedureStep, AuditSample (ISA 330/530)
2548    /// - AnalyticalProcedureResult (ISA 520)
2549    /// - InternalAuditFunction, InternalAuditReport (ISA 610)
2550    /// - RelatedParty, RelatedPartyTransaction (ISA 550)
2551    #[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        // ExternalConfirmation → Layer 1 (Governance)
2569        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        // ConfirmationResponse → Layer 1 (Governance)
2621        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        // AuditProcedureStep → Layer 1 (Governance)
2657        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        // AuditSample → Layer 1 (Governance)
2715        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        // AnalyticalProcedureResult → Layer 1 (Governance)
2751        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        // InternalAuditFunction → Layer 1 (Governance)
2804        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        // InternalAuditReport → Layer 1 (Governance)
2840        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        // RelatedParty → Layer 1 (Governance)
2885        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        // RelatedPartyTransaction → Layer 2 (Process Events — financial event)
2921        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    /// Add Bank Reconciliation documents as Layer 2 nodes.
2971    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    /// Add OCPM events as hyperedges connecting their participating objects.
3046    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                    // Ensure the object node exists
3057                    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    /// Adds compliance regulation nodes: standards (Layer 1), findings & filings (Layer 2).
3110    ///
3111    /// Creates cross-layer edges:
3112    /// - Standard → Account (GovernedByStandard) via `applicable_account_types`
3113    /// - Standard → Control (StandardToControl) via domain/process mapping
3114    /// - Finding → Control (FindingOnControl) if finding has `control_id`
3115    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        // Standards → Layer 1 (Governance)
3126        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                // Collect deferred standard→account links for cross-layer edges
3185                for _acct_type in &std.applicable_account_types {
3186                    // Deferred: resolved in build_cross_layer_edges
3187                    // We match account_type against account names/labels
3188                }
3189            }
3190        }
3191
3192        // Findings → Layer 2 (ProcessEvents)
3193        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                // Link finding → standard(s)
3240                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                // Deferred: Finding → Control
3256                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        // Filings → Layer 2 (ProcessEvents)
3264        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    // =========================================================================
3315    // New Domain Builder Methods
3316    // =========================================================================
3317
3318    /// Add tax documents as Layer 3 (Accounting Network) nodes.
3319    ///
3320    /// Creates nodes for jurisdictions, tax codes, tax lines, tax returns,
3321    /// tax provisions, and withholding tax records.
3322    #[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    /// Add treasury documents as Layer 3 (Accounting Network) nodes.
3580    ///
3581    /// Creates nodes for cash positions, cash forecasts, hedge relationships,
3582    /// and debt instruments.
3583    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    /// Add ESG documents as Layer 1 (Governance & Controls) nodes.
3771    ///
3772    /// Creates nodes for emissions, disclosures, supplier assessments,
3773    /// and climate scenarios.
3774    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    /// Add project accounting documents as Layer 3 (Accounting Network) nodes.
3947    ///
3948    /// Creates nodes for projects, earned value metrics, and milestones.
3949    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            // Flag as anomaly if schedule or cost performance is significantly off
4002            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    /// Add intercompany documents as Layer 3 (Accounting Network) nodes.
4079    ///
4080    /// Creates nodes for IC matched pairs and elimination entries.
4081    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    /// Add temporal events as Layer 2 (Process Events) nodes.
4188    ///
4189    /// Creates nodes for process evolution events, organizational events,
4190    /// and disruption events.
4191    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    /// Add AML alert nodes derived from suspicious banking transactions (Layer 2).
4325    ///
4326    /// Creates an `aml_alert` node for each suspicious transaction. These are
4327    /// separate from the `bank_transaction` nodes produced by `add_bank_documents`.
4328    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    /// Add KYC profile nodes derived from banking customers (Layer 2).
4379    ///
4380    /// Creates a `kyc_profile` node for each banking customer. These capture
4381    /// the KYC/AML risk profile rather than the transactional behavior.
4382    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    /// Tag all nodes with a `process_family` property based on their entity type.
4430    ///
4431    /// This replaces AssureTwin's entity_registry logic. Call after all nodes
4432    /// have been added and before `build()`.
4433    pub fn tag_process_family(&mut self) {
4434        for node in &mut self.nodes {
4435            let family = match node.entity_type.as_str() {
4436                // P2P (Procure-to-Pay)
4437                "purchase_order" | "goods_receipt" | "vendor_invoice" | "payment" | "p2p_pool" => {
4438                    "P2P"
4439                }
4440                // O2C (Order-to-Cash)
4441                "sales_order" | "delivery" | "customer_invoice" | "o2c_pool" => "O2C",
4442                // S2C (Source-to-Contract)
4443                "sourcing_project"
4444                | "supplier_qualification"
4445                | "rfx_event"
4446                | "supplier_bid"
4447                | "bid_evaluation"
4448                | "procurement_contract" => "S2C",
4449                // H2R (Hire-to-Retire)
4450                "payroll_run" | "time_entry" | "expense_report" | "payroll_line_item" => "H2R",
4451                // MFG (Manufacturing)
4452                "production_order" | "quality_inspection" | "cycle_count" => "MFG",
4453                // BANK (Banking)
4454                "banking_customer" | "bank_account" | "bank_transaction" | "aml_alert"
4455                | "kyc_profile" => "BANK",
4456                // AUDIT
4457                "audit_engagement"
4458                | "workpaper"
4459                | "audit_finding"
4460                | "audit_evidence"
4461                | "risk_assessment"
4462                | "professional_judgment" => "AUDIT",
4463                // R2R (Record-to-Report)
4464                "bank_reconciliation" | "bank_statement_line" | "reconciling_item" => "R2R",
4465                // TAX
4466                "tax_jurisdiction"
4467                | "tax_code"
4468                | "tax_line"
4469                | "tax_return"
4470                | "tax_provision"
4471                | "withholding_tax_record" => "TAX",
4472                // TREASURY
4473                "cash_position" | "cash_forecast" | "hedge_relationship" | "debt_instrument" => {
4474                    "TREASURY"
4475                }
4476                // ESG
4477                "emission_record"
4478                | "esg_disclosure"
4479                | "supplier_esg_assessment"
4480                | "climate_scenario" => "ESG",
4481                // PROJECT
4482                "project" | "earned_value_metric" | "project_milestone" => "PROJECT",
4483                // IC (Intercompany)
4484                "ic_matched_pair" | "elimination_entry" => "IC",
4485                // TEMPORAL
4486                "process_evolution" | "organizational_event" | "disruption_event" => "TEMPORAL",
4487                // COMPLIANCE
4488                "compliance_standard" | "compliance_finding" | "regulatory_filing" => "COMPLIANCE",
4489                // GOVERNANCE (COSO/Controls)
4490                "coso_component" | "coso_principle" | "sox_assertion" | "internal_control" => {
4491                    "GOVERNANCE"
4492                }
4493                // MASTER DATA
4494                "vendor" | "customer" | "employee" | "material" | "fixed_asset" => "MASTER_DATA",
4495                // ACCOUNTING
4496                "account" | "journal_entry" => "ACCOUNTING",
4497                // OCPM
4498                "ocpm_object" => "OCPM",
4499                // Unknown/other
4500                _ => "OTHER",
4501            };
4502            node.properties
4503                .insert("process_family".into(), Value::String(family.to_string()));
4504        }
4505    }
4506
4507    /// Build cross-layer edges linking governance to accounting and process layers.
4508    pub fn build_cross_layer_edges(&mut self) {
4509        if !self.config.include_cross_layer_edges {
4510            return;
4511        }
4512
4513        // Use pre-collected counterparty links instead of iterating all nodes
4514        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        // Compliance: Finding → Control edges
4536        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        // Compliance: Standard → Account edges (match by account label/name)
4553        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            // Look up the standard's applicable_account_types from node properties
4560            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                        // Match against account nodes by checking if name contains
4571                        for (acct_code, acct_node_id) in &self.account_node_ids {
4572                            // Get account label from node
4573                            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        // Compliance: Standard → Control edges (match by control process mapping)
4600        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                        // For SOX/audit standards, link to all controls
4612                        let is_universal = proc_strings.len() >= 5;
4613                        if is_universal {
4614                            // Link to all controls (this standard governs all processes)
4615                            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    /// Finalize and build the Hypergraph.
4634    pub fn build(mut self) -> Hypergraph {
4635        // Build cross-layer edges last (they reference all nodes)
4636        self.build_cross_layer_edges();
4637
4638        // Compute metadata
4639        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    /// Try to add a node, respecting the budget. Returns true if added.
4716    fn try_add_node(&mut self, node: HypergraphNode) -> bool {
4717        if self.node_index.contains_key(&node.id) {
4718            return false; // Already exists
4719        }
4720
4721        if !self.budget.can_add(node.layer) {
4722            return false; // Budget exceeded
4723        }
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
4735/// Map COSO component to a numeric feature.
4736fn 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
4746/// Map account type to a numeric feature.
4747fn 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
4759/// Compute features for a journal entry hyperedge.
4760fn 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(), // log amount
4780        line_count,                     // number of lines
4781        weekday,                        // weekday normalized
4782        day,                            // day of month normalized
4783        month,                          // month normalized
4784        is_month_end,                   // month-end flag
4785    ]
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        // 5 components + 17 principles = 22 nodes
4862        assert_eq!(hg.nodes.len(), 22);
4863        assert!(hg
4864            .nodes
4865            .iter()
4866            .all(|n| n.layer == HypergraphLayer::GovernanceControls));
4867        // 17 principle → component edges
4868        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        // 22 COSO + 1 control + 1 SOX assertion = 24
4889        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, // Very small budget
4915            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        // Budget for L3 is 10% of 10 = 1, so only 1 of 2 accounts should be added
4930        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}