rustkernel_procint/
simulation.rs

1//! Digital Twin Simulation Kernel
2//!
3//! GPU-accelerated process simulation for what-if analysis,
4//! resource optimization, and predictive process management.
5//!
6//! # Features
7//! - Monte Carlo simulation of process execution
8//! - Resource contention modeling
9//! - What-if scenario analysis
10//! - Bottleneck prediction
11//! - Queue dynamics simulation
12
13use rustkernel_core::error::Result;
14use rustkernel_core::traits::GpuKernel;
15use rustkernel_core::{domain::Domain, kernel::KernelMetadata};
16use std::collections::HashMap;
17
18/// Digital Twin process simulation kernel.
19///
20/// Simulates process execution using discrete event simulation
21/// with Monte Carlo sampling for activity durations.
22#[derive(Debug)]
23pub struct DigitalTwin {
24    metadata: KernelMetadata,
25}
26
27impl DigitalTwin {
28    /// Create a new DigitalTwin kernel.
29    pub fn new() -> Self {
30        Self {
31            metadata: KernelMetadata::batch("procint/digital-twin", Domain::ProcessIntelligence)
32                .with_description("Process simulation for what-if analysis and optimization"),
33        }
34    }
35
36    /// Run process simulation.
37    pub fn simulate(
38        &self,
39        model: &ProcessModel,
40        config: &SimulationConfig,
41    ) -> Result<SimulationResult> {
42        // Run multiple simulation replications
43        let mut all_traces: Vec<SimulatedTrace> = Vec::new();
44        let mut resource_utilizations: HashMap<String, f64> = HashMap::new();
45        let mut activity_stats: HashMap<String, ActivityStats> = HashMap::new();
46        let mut bottleneck_scores: HashMap<String, f64> = HashMap::new();
47
48        // Initialize resource utilization tracking
49        for resource in &model.resources {
50            resource_utilizations.insert(resource.id.clone(), 0.0);
51        }
52
53        // Initialize activity stats
54        for activity in &model.activities {
55            activity_stats.insert(
56                activity.id.clone(),
57                ActivityStats {
58                    count: 0,
59                    total_duration: 0.0,
60                    total_waiting: 0.0,
61                    min_duration: f64::MAX,
62                    max_duration: 0.0,
63                },
64            );
65        }
66
67        // Run replications (GPU would parallelize this)
68        for replication in 0..config.replications {
69            let seed = config.seed.unwrap_or(42) + replication as u64;
70            let traces = self.run_replication(model, config, seed)?;
71
72            // Collect statistics from traces
73            for trace in &traces {
74                for event in &trace.events {
75                    // Update activity stats
76                    if let Some(stats) = activity_stats.get_mut(&event.activity_id) {
77                        stats.count += 1;
78                        stats.total_duration += event.duration;
79                        stats.total_waiting += event.waiting_time;
80                        stats.min_duration = stats.min_duration.min(event.duration);
81                        stats.max_duration = stats.max_duration.max(event.duration);
82                    }
83
84                    // Update resource utilization
85                    if let Some(resource_id) = &event.resource_id {
86                        if let Some(util) = resource_utilizations.get_mut(resource_id) {
87                            *util += event.duration;
88                        }
89                    }
90                }
91            }
92
93            all_traces.extend(traces);
94        }
95
96        // Normalize statistics
97        let total_time = config.simulation_duration * config.replications as f64;
98
99        for util in resource_utilizations.values_mut() {
100            *util /= total_time;
101        }
102
103        // Compute bottleneck scores based on waiting time ratio
104        for (activity_id, stats) in &activity_stats {
105            if stats.count > 0 {
106                let avg_waiting = stats.total_waiting / stats.count as f64;
107                let avg_duration = stats.total_duration / stats.count as f64;
108                let bottleneck_score = if avg_duration > 0.0 {
109                    avg_waiting / (avg_waiting + avg_duration)
110                } else {
111                    0.0
112                };
113                bottleneck_scores.insert(activity_id.clone(), bottleneck_score);
114            }
115        }
116
117        // Find primary bottleneck
118        let primary_bottleneck = bottleneck_scores
119            .iter()
120            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
121            .map(|(k, _)| k.clone());
122
123        // Compute KPIs
124        let kpis = self.compute_kpis(&all_traces, config);
125
126        Ok(SimulationResult {
127            traces: all_traces,
128            kpis,
129            resource_utilization: resource_utilizations,
130            bottleneck_scores,
131            primary_bottleneck,
132            replications_completed: config.replications,
133        })
134    }
135
136    fn run_replication(
137        &self,
138        model: &ProcessModel,
139        config: &SimulationConfig,
140        seed: u64,
141    ) -> Result<Vec<SimulatedTrace>> {
142        use rand::SeedableRng;
143        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
144
145        let mut traces = Vec::new();
146        let mut current_time = 0.0;
147        let mut case_id = 0;
148
149        // Resource availability tracking (time when each resource becomes free)
150        let mut resource_free_at: HashMap<String, f64> = HashMap::new();
151        for resource in &model.resources {
152            for _ in 0..resource.capacity {
153                let key = format!("{}_{}", resource.id, resource_free_at.len());
154                resource_free_at.insert(key, 0.0);
155            }
156        }
157
158        // Generate arrivals and process cases
159        while current_time < config.simulation_duration {
160            // Inter-arrival time (exponential distribution)
161            let inter_arrival = self.sample_exponential(&mut rng, config.arrival_rate);
162            current_time += inter_arrival;
163
164            if current_time >= config.simulation_duration {
165                break;
166            }
167
168            // Process this case through the model
169            let trace = self.process_case(
170                model,
171                case_id,
172                current_time,
173                &mut resource_free_at,
174                &mut rng,
175            );
176            traces.push(trace);
177            case_id += 1;
178        }
179
180        Ok(traces)
181    }
182
183    fn process_case(
184        &self,
185        model: &ProcessModel,
186        case_id: u32,
187        arrival_time: f64,
188        resource_free_at: &mut HashMap<String, f64>,
189        rng: &mut rand::rngs::StdRng,
190    ) -> SimulatedTrace {
191        let mut events = Vec::new();
192        let mut current_time = arrival_time;
193
194        // Simple sequential execution through activities
195        // (Real implementation would follow process model structure)
196        for activity in &model.activities {
197            // Sample activity duration
198            let duration = self.sample_duration(&activity.duration_dist, rng);
199
200            // Find earliest available resource
201            let (resource_key, waiting_time) =
202                self.find_resource(activity, current_time, resource_free_at);
203
204            let start_time = current_time + waiting_time;
205            let end_time = start_time + duration;
206
207            // Update resource availability
208            if let Some(key) = &resource_key {
209                resource_free_at.insert(key.clone(), end_time);
210            }
211
212            events.push(SimulatedEvent {
213                activity_id: activity.id.clone(),
214                start_time,
215                end_time,
216                duration,
217                waiting_time,
218                resource_id: resource_key.map(|k| k.split('_').next().unwrap_or(&k).to_string()),
219            });
220
221            current_time = end_time;
222        }
223
224        SimulatedTrace {
225            case_id,
226            arrival_time,
227            completion_time: current_time,
228            cycle_time: current_time - arrival_time,
229            events,
230        }
231    }
232
233    fn find_resource(
234        &self,
235        activity: &Activity,
236        current_time: f64,
237        resource_free_at: &HashMap<String, f64>,
238    ) -> (Option<String>, f64) {
239        if activity.required_resources.is_empty() {
240            return (None, 0.0);
241        }
242
243        // Find earliest available resource of required type
244        let mut best_key: Option<String> = None;
245        let mut best_time = f64::MAX;
246
247        for required_resource in &activity.required_resources {
248            for (key, free_time) in resource_free_at {
249                if key.starts_with(required_resource) && *free_time < best_time {
250                    best_time = *free_time;
251                    best_key = Some(key.clone());
252                }
253            }
254        }
255
256        if let Some(key) = &best_key {
257            let waiting = (best_time - current_time).max(0.0);
258            (Some(key.clone()), waiting)
259        } else {
260            (None, 0.0)
261        }
262    }
263
264    fn sample_exponential(&self, rng: &mut rand::rngs::StdRng, rate: f64) -> f64 {
265        use rand::Rng;
266        let u: f64 = rng.random();
267        -u.ln() / rate
268    }
269
270    fn sample_duration(&self, dist: &DurationDistribution, rng: &mut rand::rngs::StdRng) -> f64 {
271        use rand::Rng;
272        match dist {
273            DurationDistribution::Constant(value) => *value,
274            DurationDistribution::Uniform { min, max } => rng.random_range(*min..=*max),
275            DurationDistribution::Normal { mean, std_dev } => {
276                // Box-Muller transform
277                let u1: f64 = rng.random();
278                let u2: f64 = rng.random();
279                let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
280                (mean + std_dev * z).max(0.0)
281            }
282            DurationDistribution::Exponential { mean } => {
283                let u: f64 = rng.random();
284                -mean * u.ln()
285            }
286            DurationDistribution::Triangular { min, mode, max } => {
287                let u: f64 = rng.random();
288                let fc = (mode - min) / (max - min);
289                if u < fc {
290                    min + ((max - min) * (mode - min) * u).sqrt()
291                } else {
292                    max - ((max - min) * (max - mode) * (1.0 - u)).sqrt()
293                }
294            }
295        }
296    }
297
298    fn compute_kpis(&self, traces: &[SimulatedTrace], config: &SimulationConfig) -> ProcessKPIs {
299        if traces.is_empty() {
300            return ProcessKPIs::default();
301        }
302
303        let cycle_times: Vec<f64> = traces.iter().map(|t| t.cycle_time).collect();
304        let n = cycle_times.len() as f64;
305
306        let avg_cycle_time = cycle_times.iter().sum::<f64>() / n;
307        let min_cycle_time = cycle_times.iter().cloned().fold(f64::INFINITY, f64::min);
308        let max_cycle_time = cycle_times
309            .iter()
310            .cloned()
311            .fold(f64::NEG_INFINITY, f64::max);
312
313        // Standard deviation
314        let variance = cycle_times
315            .iter()
316            .map(|t| (t - avg_cycle_time).powi(2))
317            .sum::<f64>()
318            / n;
319        let std_cycle_time = variance.sqrt();
320
321        // Percentiles
322        let mut sorted = cycle_times.clone();
323        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
324
325        let p50 = self.percentile(&sorted, 0.5);
326        let p90 = self.percentile(&sorted, 0.9);
327        let p95 = self.percentile(&sorted, 0.95);
328        let p99 = self.percentile(&sorted, 0.99);
329
330        // Throughput
331        let throughput = traces.len() as f64 / config.simulation_duration;
332
333        // WIP (Work in Progress) - Little's Law approximation
334        let wip = throughput * avg_cycle_time;
335
336        // Service level (percentage completing within target)
337        let service_level = if let Some(target) = config.target_cycle_time {
338            let within_target = cycle_times.iter().filter(|&&t| t <= target).count();
339            within_target as f64 / n
340        } else {
341            1.0
342        };
343
344        ProcessKPIs {
345            avg_cycle_time,
346            min_cycle_time,
347            max_cycle_time,
348            std_cycle_time,
349            p50_cycle_time: p50,
350            p90_cycle_time: p90,
351            p95_cycle_time: p95,
352            p99_cycle_time: p99,
353            throughput,
354            work_in_progress: wip,
355            service_level,
356            total_cases: traces.len(),
357        }
358    }
359
360    fn percentile(&self, sorted: &[f64], p: f64) -> f64 {
361        if sorted.is_empty() {
362            return 0.0;
363        }
364        let idx = ((sorted.len() - 1) as f64 * p) as usize;
365        sorted[idx.min(sorted.len() - 1)]
366    }
367}
368
369impl Default for DigitalTwin {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375impl GpuKernel for DigitalTwin {
376    fn metadata(&self) -> &KernelMetadata {
377        &self.metadata
378    }
379
380    fn validate(&self) -> Result<()> {
381        Ok(())
382    }
383}
384
385/// Process model definition.
386#[derive(Debug, Clone)]
387pub struct ProcessModel {
388    /// Model identifier.
389    pub id: String,
390    /// Activities in the process.
391    pub activities: Vec<Activity>,
392    /// Available resources.
393    pub resources: Vec<Resource>,
394    /// ProcessTransitions between activities.
395    pub transitions: Vec<ProcessTransition>,
396}
397
398/// Activity definition.
399#[derive(Debug, Clone)]
400pub struct Activity {
401    /// Activity identifier.
402    pub id: String,
403    /// Activity name.
404    pub name: String,
405    /// Duration distribution.
406    pub duration_dist: DurationDistribution,
407    /// Required resource types.
408    pub required_resources: Vec<String>,
409}
410
411/// Resource definition.
412#[derive(Debug, Clone)]
413pub struct Resource {
414    /// Resource identifier.
415    pub id: String,
416    /// Resource name.
417    pub name: String,
418    /// Number of instances.
419    pub capacity: u32,
420    /// Cost per time unit.
421    pub cost_per_unit: f64,
422}
423
424/// ProcessTransition between activities.
425#[derive(Debug, Clone)]
426pub struct ProcessTransition {
427    /// Source activity.
428    pub from: String,
429    /// Target activity.
430    pub to: String,
431    /// Probability (for XOR splits).
432    pub probability: f64,
433}
434
435/// Duration distribution types.
436#[derive(Debug, Clone)]
437pub enum DurationDistribution {
438    /// Fixed duration.
439    Constant(f64),
440    /// Uniform distribution between min and max.
441    Uniform {
442        /// Minimum duration.
443        min: f64,
444        /// Maximum duration.
445        max: f64,
446    },
447    /// Normal (Gaussian) distribution.
448    Normal {
449        /// Mean of the distribution.
450        mean: f64,
451        /// Standard deviation.
452        std_dev: f64,
453    },
454    /// Exponential distribution.
455    Exponential {
456        /// Mean of the distribution.
457        mean: f64,
458    },
459    /// Triangular distribution.
460    Triangular {
461        /// Minimum duration.
462        min: f64,
463        /// Most likely duration.
464        mode: f64,
465        /// Maximum duration.
466        max: f64,
467    },
468}
469
470/// Simulation configuration.
471#[derive(Debug, Clone)]
472pub struct SimulationConfig {
473    /// Number of simulation replications.
474    pub replications: u32,
475    /// Duration of each replication (time units).
476    pub simulation_duration: f64,
477    /// Arrival rate (cases per time unit).
478    pub arrival_rate: f64,
479    /// Random seed for reproducibility.
480    pub seed: Option<u64>,
481    /// Target cycle time for service level calculation.
482    pub target_cycle_time: Option<f64>,
483    /// Warm-up period to exclude from statistics.
484    pub warmup_period: Option<f64>,
485}
486
487impl Default for SimulationConfig {
488    fn default() -> Self {
489        Self {
490            replications: 100,
491            simulation_duration: 480.0, // 8 hours
492            arrival_rate: 0.1,          // 10 cases per hour
493            seed: Some(42),
494            target_cycle_time: None,
495            warmup_period: None,
496        }
497    }
498}
499
500/// Simulation result.
501#[derive(Debug, Clone)]
502pub struct SimulationResult {
503    /// Simulated traces.
504    pub traces: Vec<SimulatedTrace>,
505    /// Process KPIs.
506    pub kpis: ProcessKPIs,
507    /// Resource utilization (0-1).
508    pub resource_utilization: HashMap<String, f64>,
509    /// Bottleneck scores by activity.
510    pub bottleneck_scores: HashMap<String, f64>,
511    /// Primary bottleneck activity.
512    pub primary_bottleneck: Option<String>,
513    /// Number of replications completed.
514    pub replications_completed: u32,
515}
516
517/// Simulated process trace.
518#[derive(Debug, Clone)]
519pub struct SimulatedTrace {
520    /// Case identifier.
521    pub case_id: u32,
522    /// Arrival time.
523    pub arrival_time: f64,
524    /// Completion time.
525    pub completion_time: f64,
526    /// Total cycle time.
527    pub cycle_time: f64,
528    /// Simulated events.
529    pub events: Vec<SimulatedEvent>,
530}
531
532/// Simulated event.
533#[derive(Debug, Clone)]
534pub struct SimulatedEvent {
535    /// Activity identifier.
536    pub activity_id: String,
537    /// Start time.
538    pub start_time: f64,
539    /// End time.
540    pub end_time: f64,
541    /// Actual duration.
542    pub duration: f64,
543    /// Time spent waiting for resources.
544    pub waiting_time: f64,
545    /// Resource used (if any).
546    pub resource_id: Option<String>,
547}
548
549/// Process key performance indicators.
550#[derive(Debug, Clone, Default)]
551pub struct ProcessKPIs {
552    /// Average cycle time.
553    pub avg_cycle_time: f64,
554    /// Minimum cycle time.
555    pub min_cycle_time: f64,
556    /// Maximum cycle time.
557    pub max_cycle_time: f64,
558    /// Standard deviation of cycle time.
559    pub std_cycle_time: f64,
560    /// 50th percentile cycle time.
561    pub p50_cycle_time: f64,
562    /// 90th percentile cycle time.
563    pub p90_cycle_time: f64,
564    /// 95th percentile cycle time.
565    pub p95_cycle_time: f64,
566    /// 99th percentile cycle time.
567    pub p99_cycle_time: f64,
568    /// Throughput (cases per time unit).
569    pub throughput: f64,
570    /// Average work in progress.
571    pub work_in_progress: f64,
572    /// Service level (fraction within target).
573    pub service_level: f64,
574    /// Total cases processed.
575    pub total_cases: usize,
576}
577
578/// Internal activity statistics tracking.
579struct ActivityStats {
580    count: usize,
581    total_duration: f64,
582    total_waiting: f64,
583    min_duration: f64,
584    max_duration: f64,
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    fn create_test_model() -> ProcessModel {
592        ProcessModel {
593            id: "test_process".to_string(),
594            activities: vec![
595                Activity {
596                    id: "receive".to_string(),
597                    name: "Receive Request".to_string(),
598                    duration_dist: DurationDistribution::Normal {
599                        mean: 5.0,
600                        std_dev: 1.0,
601                    },
602                    required_resources: vec!["clerk".to_string()],
603                },
604                Activity {
605                    id: "review".to_string(),
606                    name: "Review Request".to_string(),
607                    duration_dist: DurationDistribution::Triangular {
608                        min: 10.0,
609                        mode: 20.0,
610                        max: 45.0,
611                    },
612                    required_resources: vec!["analyst".to_string()],
613                },
614                Activity {
615                    id: "approve".to_string(),
616                    name: "Approve Request".to_string(),
617                    duration_dist: DurationDistribution::Exponential { mean: 8.0 },
618                    required_resources: vec!["manager".to_string()],
619                },
620            ],
621            resources: vec![
622                Resource {
623                    id: "clerk".to_string(),
624                    name: "Clerk".to_string(),
625                    capacity: 3,
626                    cost_per_unit: 25.0,
627                },
628                Resource {
629                    id: "analyst".to_string(),
630                    name: "Analyst".to_string(),
631                    capacity: 2,
632                    cost_per_unit: 50.0,
633                },
634                Resource {
635                    id: "manager".to_string(),
636                    name: "Manager".to_string(),
637                    capacity: 1,
638                    cost_per_unit: 100.0,
639                },
640            ],
641            transitions: vec![
642                ProcessTransition {
643                    from: "receive".to_string(),
644                    to: "review".to_string(),
645                    probability: 1.0,
646                },
647                ProcessTransition {
648                    from: "review".to_string(),
649                    to: "approve".to_string(),
650                    probability: 1.0,
651                },
652            ],
653        }
654    }
655
656    #[test]
657    fn test_digital_twin_metadata() {
658        let kernel = DigitalTwin::new();
659        let metadata = kernel.metadata();
660        assert_eq!(metadata.id, "procint/digital-twin");
661        assert_eq!(metadata.domain, Domain::ProcessIntelligence);
662    }
663
664    #[test]
665    fn test_simulation_basic() {
666        let kernel = DigitalTwin::new();
667        let model = create_test_model();
668        let config = SimulationConfig {
669            replications: 10,
670            simulation_duration: 100.0,
671            arrival_rate: 0.2,
672            seed: Some(42),
673            ..Default::default()
674        };
675
676        let result = kernel.simulate(&model, &config).unwrap();
677
678        assert!(!result.traces.is_empty());
679        assert!(result.kpis.avg_cycle_time > 0.0);
680        assert!(result.kpis.throughput > 0.0);
681        assert_eq!(result.replications_completed, 10);
682    }
683
684    #[test]
685    fn test_resource_utilization() {
686        let kernel = DigitalTwin::new();
687        let model = create_test_model();
688        let config = SimulationConfig {
689            replications: 50,
690            simulation_duration: 200.0,
691            arrival_rate: 0.15,
692            seed: Some(123),
693            ..Default::default()
694        };
695
696        let result = kernel.simulate(&model, &config).unwrap();
697
698        // Check that resource utilization is computed
699        assert!(!result.resource_utilization.is_empty());
700
701        // Utilization should be between 0 and some reasonable upper bound
702        for util in result.resource_utilization.values() {
703            assert!(*util >= 0.0);
704        }
705    }
706
707    #[test]
708    fn test_bottleneck_detection() {
709        let kernel = DigitalTwin::new();
710        let model = create_test_model();
711        let config = SimulationConfig {
712            replications: 20,
713            simulation_duration: 150.0,
714            arrival_rate: 0.25, // High arrival rate to create congestion
715            seed: Some(456),
716            ..Default::default()
717        };
718
719        let result = kernel.simulate(&model, &config).unwrap();
720
721        // Should identify some bottleneck
722        assert!(!result.bottleneck_scores.is_empty());
723
724        // Bottleneck scores should be between 0 and 1
725        for score in result.bottleneck_scores.values() {
726            assert!(*score >= 0.0 && *score <= 1.0);
727        }
728    }
729
730    #[test]
731    fn test_kpis_calculation() {
732        let kernel = DigitalTwin::new();
733        let model = create_test_model();
734        let config = SimulationConfig {
735            replications: 30,
736            simulation_duration: 300.0,
737            arrival_rate: 0.1,
738            seed: Some(789),
739            target_cycle_time: Some(100.0),
740            ..Default::default()
741        };
742
743        let result = kernel.simulate(&model, &config).unwrap();
744
745        // Verify KPIs are reasonable
746        assert!(result.kpis.min_cycle_time <= result.kpis.avg_cycle_time);
747        assert!(result.kpis.avg_cycle_time <= result.kpis.max_cycle_time);
748        assert!(result.kpis.p50_cycle_time <= result.kpis.p90_cycle_time);
749        assert!(result.kpis.p90_cycle_time <= result.kpis.p95_cycle_time);
750        assert!(result.kpis.service_level >= 0.0 && result.kpis.service_level <= 1.0);
751    }
752
753    #[test]
754    fn test_duration_distributions() {
755        let kernel = DigitalTwin::new();
756        use rand::SeedableRng;
757        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
758
759        // Test constant
760        let constant = DurationDistribution::Constant(10.0);
761        assert_eq!(kernel.sample_duration(&constant, &mut rng), 10.0);
762
763        // Test uniform
764        let uniform = DurationDistribution::Uniform {
765            min: 5.0,
766            max: 15.0,
767        };
768        let sample = kernel.sample_duration(&uniform, &mut rng);
769        assert!((5.0..=15.0).contains(&sample));
770
771        // Test normal
772        let normal = DurationDistribution::Normal {
773            mean: 10.0,
774            std_dev: 2.0,
775        };
776        let sample = kernel.sample_duration(&normal, &mut rng);
777        assert!(sample >= 0.0); // Should be non-negative due to max(0.0)
778
779        // Test exponential
780        let exponential = DurationDistribution::Exponential { mean: 10.0 };
781        let sample = kernel.sample_duration(&exponential, &mut rng);
782        assert!(sample >= 0.0);
783
784        // Test triangular
785        let triangular = DurationDistribution::Triangular {
786            min: 5.0,
787            mode: 10.0,
788            max: 20.0,
789        };
790        let sample = kernel.sample_duration(&triangular, &mut rng);
791        assert!((5.0..=20.0).contains(&sample));
792    }
793}