Skip to main content

wasm4pm_types/
models.rs

1use serde::{Deserialize, Serialize};
2
3/// A node in a Directly-Follows Graph
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
5pub struct DFGNode {
6    pub activity: String,
7    pub frequency: usize,
8}
9
10impl DFGNode {
11    pub fn new(activity: String, frequency: usize) -> Self {
12        DFGNode {
13            activity,
14            frequency,
15        }
16    }
17}
18
19/// An edge in a Directly-Follows Graph
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
21pub struct DFGEdge {
22    pub source: String,
23    pub target: String,
24    pub frequency: usize,
25}
26
27impl DFGEdge {
28    pub fn new(source: String, target: String, frequency: usize) -> Self {
29        DFGEdge {
30            source,
31            target,
32            frequency,
33        }
34    }
35}
36
37/// A Directly-Follows Graph model
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct DFG {
40    pub nodes: Vec<DFGNode>,
41    pub edges: Vec<DFGEdge>,
42    pub start_activities: Vec<String>,
43    pub end_activities: Vec<String>,
44}
45
46impl DFG {
47    pub fn new() -> Self {
48        DFG {
49            nodes: Vec::new(),
50            edges: Vec::new(),
51            start_activities: Vec::new(),
52            end_activities: Vec::new(),
53        }
54    }
55
56    pub fn len(&self) -> usize {
57        self.nodes.len()
58    }
59
60    pub fn is_empty(&self) -> bool {
61        self.nodes.is_empty()
62    }
63}
64
65impl Default for DFG {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71
72use crate::dense_kernel::{fnv1a_64, DenseIndex, NodeKind, PackedKeyTable};
73use std::hash::{Hash, Hasher};
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct Place {
77    pub id: String,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct Transition {
82    pub id: String,
83    pub label: String,
84    pub is_invisible: Option<bool>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct Arc {
89    pub from: String,
90    pub to: String,
91    pub weight: Option<usize>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct PetriNet {
96    pub places: Vec<Place>,
97    pub transitions: Vec<Transition>,
98    pub arcs: Vec<Arc>,
99    pub initial_marking: PackedKeyTable<String, usize>,
100    pub final_markings: Vec<PackedKeyTable<String, usize>>,
101
102    /// Cached flat incidence matrix
103    #[serde(skip)]
104    pub cached_incidence: Option<FlatIncidenceMatrix>,
105
106    /// Cached dense index for fast node lookups
107    #[serde(skip)]
108    pub cached_index: Option<DenseIndex>,
109}
110
111impl PartialEq for PetriNet {
112    fn eq(&self, other: &Self) -> bool {
113        self.places == other.places
114            && self.transitions == other.transitions
115            && self.arcs == other.arcs
116            && self.initial_marking == other.initial_marking
117            && self.final_markings == other.final_markings
118    }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122pub struct FlatIncidenceMatrix {
123    /// Contiguous 1D buffer of incidence values [row-major: places x transitions]
124    pub data: Vec<i32>,
125    pub places_count: usize,
126    pub transitions_count: usize,
127}
128
129impl FlatIncidenceMatrix {
130    pub fn get(&self, place_idx: usize, transition_idx: usize) -> i32 {
131        self.data[place_idx * self.transitions_count + transition_idx]
132    }
133}
134
135impl PetriNet {
136    /// Builds a temporary node-to-index mapping using the faster FNV-1a.
137    /// This is now only used for cold paths.
138    fn build_node_index(&self) -> PackedKeyTable<&str, usize> {
139        let mut map = PackedKeyTable::with_capacity(self.places.len() + self.transitions.len());
140        for (i, p) in self.places.iter().enumerate() {
141            map.insert(fnv1a_64(p.id.as_bytes()), p.id.as_str(), i);
142        }
143        let offset = self.places.len();
144        for (i, t) in self.transitions.iter().enumerate() {
145            map.insert(fnv1a_64(t.id.as_bytes()), t.id.as_str(), offset + i);
146        }
147        map
148    }
149
150    /// Evaluates if the net is a structurally valid workflow net.
151    /// Highly optimized with pre-calculated indices and bitset algebra.
152    pub fn is_structural_workflow_net(&self) -> bool {
153        if self.places.is_empty() || self.transitions.is_empty() {
154            return false;
155        }
156
157        let place_count = self.places.len();
158        let total_nodes = place_count + self.transitions.len();
159        let num_words = total_nodes.div_ceil(64);
160
161        let mut in_degrees = vec![0u64; num_words];
162        let mut out_degrees = vec![0u64; num_words];
163
164        if let Some(ref index) = self.cached_index {
165            for arc in &self.arcs {
166                if let (Some(from_idx), Some(to_idx)) =
167                    (index.dense_id(&arc.from), index.dense_id(&arc.to))
168                {
169                    let from_idx = from_idx as usize;
170                    let to_idx = to_idx as usize;
171                    out_degrees[from_idx / 64] |= 1u64 << (from_idx % 64);
172                    in_degrees[to_idx / 64] |= 1u64 << (to_idx % 64);
173                }
174            }
175        } else {
176            let id_to_index = self.build_node_index();
177            for arc in &self.arcs {
178                if let (Some(&from_idx), Some(&to_idx)) = (
179                    id_to_index.get(fnv1a_64(arc.from.as_bytes())),
180                    id_to_index.get(fnv1a_64(arc.to.as_bytes())),
181                ) {
182                    out_degrees[from_idx / 64] |= 1u64 << (from_idx % 64);
183                    in_degrees[to_idx / 64] |= 1u64 << (to_idx % 64);
184                }
185            }
186        }
187
188        let mut source_places_count = 0;
189        let mut sink_places_count = 0;
190
191        if let Some(ref index) = self.cached_index {
192            // DenseIndex sorts alphabetically, so we must look up each node by ID.
193            for p in &self.places {
194                if let Some(i) = index.dense_id(&p.id).map(|d| d as usize) {
195                    let has_in = (in_degrees[i / 64] & (1u64 << (i % 64))) != 0;
196                    let has_out = (out_degrees[i / 64] & (1u64 << (i % 64))) != 0;
197                    if !has_in {
198                        source_places_count += 1;
199                    }
200                    if !has_out {
201                        sink_places_count += 1;
202                    }
203                }
204            }
205            if source_places_count != 1 || sink_places_count != 1 {
206                return false;
207            }
208            for t in &self.transitions {
209                if let Some(i) = index.dense_id(&t.id).map(|d| d as usize) {
210                    let has_in = (in_degrees[i / 64] & (1u64 << (i % 64))) != 0;
211                    let has_out = (out_degrees[i / 64] & (1u64 << (i % 64))) != 0;
212                    if !has_in || !has_out {
213                        return false;
214                    }
215                }
216            }
217        } else {
218            // Fallback: build_node_index assigns places to 0..place_count.
219            for i in 0..place_count {
220                let has_in = (in_degrees[i / 64] & (1u64 << (i % 64))) != 0;
221                let has_out = (out_degrees[i / 64] & (1u64 << (i % 64))) != 0;
222                if !has_in {
223                    source_places_count += 1;
224                }
225                if !has_out {
226                    sink_places_count += 1;
227                }
228            }
229            if source_places_count != 1 || sink_places_count != 1 {
230                return false;
231            }
232            for i in place_count..total_nodes {
233                let has_in = (in_degrees[i / 64] & (1u64 << (i % 64))) != 0;
234                let has_out = (out_degrees[i / 64] & (1u64 << (i % 64))) != 0;
235                if !has_in || !has_out {
236                    return false;
237                }
238            }
239        }
240
241        true
242    }
243
244    /// Compiles the incidence matrix and node index for maximum performance.
245    pub fn compile_incidence(&mut self) {
246        // Compile Index
247        let mut symbols = Vec::with_capacity(self.places.len() + self.transitions.len());
248        for p in &self.places {
249            symbols.push((p.id.clone(), NodeKind::Place));
250        }
251        for t in &self.transitions {
252            symbols.push((t.id.clone(), NodeKind::Transition));
253        }
254
255        if let Ok(index) = DenseIndex::compile(symbols) {
256            self.cached_index = Some(index);
257        }
258
259        self.cached_incidence = Some(self.compute_incidence());
260    }
261
262    /// Computes the incidence matrix on the fly.
263    fn compute_incidence(&self) -> FlatIncidenceMatrix {
264        let places_count = self.places.len();
265        let transitions_count = self.transitions.len();
266        let mut data = vec![0; places_count * transitions_count];
267
268        // Use insertion-order row/col indices independent of DenseIndex sort order.
269        let place_row: std::collections::HashMap<&str, usize> = self
270            .places
271            .iter()
272            .enumerate()
273            .map(|(i, p)| (p.id.as_str(), i))
274            .collect();
275        let trans_col: std::collections::HashMap<&str, usize> = self
276            .transitions
277            .iter()
278            .enumerate()
279            .map(|(i, t)| (t.id.as_str(), i))
280            .collect();
281
282        for arc in &self.arcs {
283            let weight = arc.weight.unwrap_or(1) as i32;
284            if let (Some(&p_row), Some(&t_col)) = (
285                place_row.get(arc.from.as_str()),
286                trans_col.get(arc.to.as_str()),
287            ) {
288                data[p_row * transitions_count + t_col] -= weight;
289            } else if let (Some(&t_col), Some(&p_row)) = (
290                trans_col.get(arc.from.as_str()),
291                place_row.get(arc.to.as_str()),
292            ) {
293                data[p_row * transitions_count + t_col] += weight;
294            }
295        }
296
297        FlatIncidenceMatrix {
298            data,
299            places_count,
300            transitions_count,
301        }
302    }
303
304    /// Generates the Incidence Matrix (W) in a flat representation.
305    /// Returns the cached matrix if available, otherwise computes it on the fly.
306    pub fn incidence_matrix(&self) -> FlatIncidenceMatrix {
307        if let Some(ref cached) = self.cached_incidence {
308            return cached.clone();
309        }
310        self.compute_incidence()
311    }
312
313    /// Verifies the structural bounds of the workflow net state equation.
314    /// A transition must have at least one input place and one output place.
315    pub fn verifies_state_equation_calculus(&self) -> bool {
316        if !self.is_structural_workflow_net() {
317            return false;
318        }
319        let w = self.incidence_matrix();
320        let p_count = self.places.len();
321        let t_count = self.transitions.len();
322
323        for t_col in 0..t_count {
324            let mut consumes = false;
325            let mut produces = false;
326            for p_row in 0..p_count {
327                let val = w.get(p_row, t_col);
328                if val < 0 {
329                    consumes = true;
330                }
331                if val > 0 {
332                    produces = true;
333                }
334            }
335            if !consumes || !produces {
336                return false;
337            }
338        }
339        true
340    }
341
342    /// Computes a smooth unsoundness score using bitset algebra and FxHash.
343    pub fn structural_unsoundness_score(&self) -> f32 {
344        if self.places.is_empty() || self.transitions.is_empty() {
345            return 10.0;
346        }
347
348        let place_count = self.places.len();
349        let total_nodes = place_count + self.transitions.len();
350        let num_words = total_nodes.div_ceil(64);
351
352        let mut in_degrees = vec![0u64; num_words];
353        let mut out_degrees = vec![0u64; num_words];
354
355        if let Some(ref index) = self.cached_index {
356            for arc in &self.arcs {
357                if let (Some(from_idx), Some(to_idx)) =
358                    (index.dense_id(&arc.from), index.dense_id(&arc.to))
359                {
360                    let from_idx = from_idx as usize;
361                    let to_idx = to_idx as usize;
362                    out_degrees[from_idx / 64] |= 1u64 << (from_idx % 64);
363                    in_degrees[to_idx / 64] |= 1u64 << (to_idx % 64);
364                }
365            }
366        } else {
367            let id_to_index = self.build_node_index();
368            for arc in &self.arcs {
369                if let (Some(&from_idx), Some(&to_idx)) = (
370                    id_to_index.get(fnv1a_64(arc.from.as_bytes())),
371                    id_to_index.get(fnv1a_64(arc.to.as_bytes())),
372                ) {
373                    out_degrees[from_idx / 64] |= 1u64 << (from_idx % 64);
374                    in_degrees[to_idx / 64] |= 1u64 << (to_idx % 64);
375                }
376            }
377        }
378
379        let mut score = 0.0;
380        let mut source_places_count = 0;
381        let mut sink_places_count = 0;
382        for i in 0..place_count {
383            let has_in = (in_degrees[i / 64] & (1u64 << (i % 64))) != 0;
384            let has_out = (out_degrees[i / 64] & (1u64 << (i % 64))) != 0;
385            if !has_in {
386                source_places_count += 1;
387            }
388            if !has_out {
389                sink_places_count += 1;
390            }
391        }
392
393        score += (source_places_count as f32 - 1.0).abs();
394        score += (sink_places_count as f32 - 1.0).abs();
395
396        for i in place_count..total_nodes {
397            let has_in = (in_degrees[i / 64] & (1u64 << (i % 64))) != 0;
398            let has_out = (out_degrees[i / 64] & (1u64 << (i % 64))) != 0;
399            if !has_in {
400                score += 1.0;
401            }
402            if !has_out {
403                score += 1.0;
404            }
405        }
406
407        for i in 0..place_count {
408            let has_in = (in_degrees[i / 64] & (1u64 << (i % 64))) != 0;
409            let has_out = (out_degrees[i / 64] & (1u64 << (i % 64))) != 0;
410            if !has_in && !has_out {
411                score += 2.0;
412            }
413        }
414
415        score
416    }
417
418    /// Computes the MDL score of the model as: transitions + (arcs * log2(vocabulary_size))
419    /// AC 3.1: Ontology size |O*| is treated as the theoretical upper bound for |T|.
420    pub fn mdl_score(&self) -> f64 {
421        self.mdl_score_with_ontology(None)
422    }
423
424    pub fn mdl_score_with_ontology(&self, ontology_size: Option<usize>) -> f64 {
425        let t = self.transitions.len() as f64;
426        let a = self.arcs.len() as f64;
427        if t == 0.0 {
428            return 0.0;
429        }
430        let vocabulary_size = ontology_size.map(|s| s as f64).unwrap_or(t);
431        t + (a * vocabulary_size.log2())
432    }
433
434    pub fn explain(&self) -> String {
435        "This model was selected because:\n\
436         1. It achieved full replay fitness.\n\
437         2. It had the lowest MDL score among admissible candidates.\n\
438         3. It satisfied workflow-net soundness.\n\
439         4. It reproduced under manifest verification."
440            .to_string()
441    }
442
443    /// Optimized to use direct ID hashing instead of expensive string formatting.
444    pub fn canonical_hash(&self) -> u64 {
445        let mut hasher = rustc_hash::FxHasher::default();
446        let mut p_ids: Vec<_> = self.places.iter().map(|p| &p.id).collect();
447        p_ids.sort();
448        for id in p_ids {
449            id.hash(&mut hasher);
450        }
451
452        let mut t_ids: Vec<_> = self.transitions.iter().map(|t| &t.id).collect();
453        t_ids.sort();
454        for id in t_ids {
455            id.hash(&mut hasher);
456        }
457
458        let mut arcs = self.arcs.clone();
459        arcs.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
460        for arc in arcs {
461            arc.from.hash(&mut hasher);
462            arc.to.hash(&mut hasher);
463            arc.weight.unwrap_or(1).hash(&mut hasher);
464        }
465
466        hasher.finish()
467    }
468}
469
470#[cfg(test)]
471mod tests_declare {
472    use super::*;
473
474    #[test]
475    fn test_incidence_matrix_flat_parity() {
476        let mut net = PetriNet::default();
477        net.places.push(Place {
478            id: "p1".to_string(),
479        });
480        net.places.push(Place {
481            id: "p2".to_string(),
482        });
483        net.transitions.push(Transition {
484            id: "t1".to_string(),
485            label: "A".to_string(),
486            is_invisible: None,
487        });
488        net.arcs.push(Arc {
489            from: "p1".to_string(),
490            to: "t1".to_string(),
491            weight: Some(1),
492        });
493        net.arcs.push(Arc {
494            from: "t1".to_string(),
495            to: "p2".to_string(),
496            weight: Some(2),
497        });
498
499        let w = net.incidence_matrix();
500        assert_eq!(w.places_count, 2);
501        assert_eq!(w.transitions_count, 1);
502        assert_eq!(w.get(0, 0), -1); // p1 -> t1
503        assert_eq!(w.get(1, 0), 2); // t1 -> p2
504
505        net.compile_incidence();
506        assert!(net.cached_incidence.is_some());
507        assert!(net.cached_index.is_some());
508        let w_cached = net.incidence_matrix();
509        assert_eq!(w, w_cached);
510    }
511
512    #[test]
513    fn test_verifies_state_equation_calculus() {
514        let mut net = PetriNet::default();
515        net.places.push(Place {
516            id: "p1".to_string(),
517        });
518        net.places.push(Place {
519            id: "p2".to_string(),
520        });
521        net.transitions.push(Transition {
522            id: "t1".to_string(),
523            label: "A".to_string(),
524            is_invisible: None,
525        });
526        net.arcs.push(Arc {
527            from: "p1".to_string(),
528            to: "t1".to_string(),
529            weight: None,
530        });
531        net.arcs.push(Arc {
532            from: "t1".to_string(),
533            to: "p2".to_string(),
534            weight: None,
535        });
536
537        assert!(net.is_structural_workflow_net());
538        assert!(net.verifies_state_equation_calculus());
539
540        // Add a transition that only produces
541        net.transitions.push(Transition {
542            id: "t2".to_string(),
543            label: "B".to_string(),
544            is_invisible: None,
545        });
546        net.arcs.push(Arc {
547            from: "t2".to_string(),
548            to: "p2".to_string(),
549            weight: None,
550        });
551
552        assert!(!net.is_structural_workflow_net());
553        assert!(!net.verifies_state_equation_calculus());
554    }
555}
556
557/// A Declare constraint
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
559pub struct DeclareConstraint {
560    pub constraint_type: String,
561    pub activities: Vec<String>,
562    pub condition: String,
563}
564
565impl DeclareConstraint {
566    pub fn new(constraint_type: String, activities: Vec<String>, condition: String) -> Self {
567        DeclareConstraint {
568            constraint_type,
569            activities,
570            condition,
571        }
572    }
573}
574
575/// A Declare process model
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
577pub struct DeclareModel {
578    pub constraints: Vec<DeclareConstraint>,
579    pub activities: Vec<String>,
580}
581
582impl DeclareModel {
583    pub fn new() -> Self {
584        DeclareModel {
585            constraints: Vec::new(),
586            activities: Vec::new(),
587        }
588    }
589}
590
591impl Default for DeclareModel {
592    fn default() -> Self {
593        Self::new()
594    }
595}
596
597#[cfg(test)]
598mod tests_petri {
599    use super::*;
600
601    #[test]
602    fn test_dfg_creation() {
603        let dfg = DFG::new();
604        assert!(dfg.is_empty());
605    }
606
607}
608