1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
//! COMBINATORIAL MAXIMALISM: Feature 2.1 (Model Logic)
//!
//! Implement the full conversion from Receipt events to OCEL and subsequently
//! to a Petri Net model using wasm4pm-compat patterns. No placeholders.
//!
//! This module provides the core process mining logic for Affidavit, bridging
//! the gap between the immutable event chain (Receipt) and the discovered
//! process model (Petri Net). It leverages the object-centric nature of
//! OCEL to discover causal dependencies that are more nuanced than simple
//! linear sequences.
use crate::types::{AdmittedReceipt, Receipt};
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use wasm4pm_compat::ocel::{OCELEvent, OCELObject, OCELRelationship, OCEL};
use wasm4pm_compat::petri::{Arc, Marking, PetriNet, Place, Transition};
/// Errors raised during the process mining lifecycle.
#[derive(Debug, Error)]
pub enum MiningError {
/// Failed to mine a Petri net using the Heuristic Inductive Miner.
#[error("wasm4pm HIM failed: {0}")]
Him(String),
/// The receipt was not admitted or failed admission checks.
#[error("admission refused: {0}")]
Admission(String),
/// The conversion from Receipt to OCEL format failed.
#[error("OCEL conversion failed: {0}")]
OcelConversion(String),
}
/// Convert an Affidavit [`Receipt`] into an [`OCEL`] 2.0 log.
///
/// This projection preserves the object-centric relationships defined in the
/// receipt, mapping every [`crate::types::OperationEvent`] to an [`OCELEvent`]
/// and ensuring all referenced objects are declared in the log.
pub fn receipt_to_ocel(receipt: &Receipt) -> Result<OCEL, MiningError> {
let mut objects_map = HashMap::new();
let mut ocel_events = Vec::new();
for ev in &receipt.events {
let mut ocel_ev = OCELEvent::new(ev.id.clone(), &ev.event_type);
for obj_ref in &ev.objects {
// Ensure the object exists in the object set.
objects_map
.entry(obj_ref.id.clone())
.or_insert_with(|| OCELObject::new(obj_ref.id.clone(), &obj_ref.obj_type));
// Add the relationship between this event and the object.
// We preserve the qualifier (role) if present in the receipt.
let qualifier = obj_ref
.qualifier
.clone()
.unwrap_or_else(|| "unspecified".to_string());
ocel_ev.relationships.push(OCELRelationship {
object_id: obj_ref.id.clone(),
qualifier,
});
}
ocel_events.push(ocel_ev);
}
let ocel_objects: Vec<_> = objects_map.into_values().collect();
// Construct the OCEL log. wasm4pm-compat handles the internal indexing.
Ok(OCEL::new(ocel_events, ocel_objects))
}
/// Discover a Petri Net model from an admitted receipt using a maximalist
/// Heuristic Inductive Miner (HIM) pattern.
///
/// The discovery follows these steps:
/// 1. **Transition Discovery**: Every unique `event_type` in the log becomes
/// a visible transition in the Petri net.
/// 2. **Causal Discovery**: Analyzes the log for "directly-follows" relations.
/// It uses both a global sequence perspective and an object-centric
/// perspective (tracing individual object lifecycles).
/// 3. **Place Synthesis**: Creates places to represent the observed causality:
/// - A `start` place feeding all potential initial activities.
/// - Causal places `p_a_b` for every observed transition from `a` to `b`.
/// - An `end` place collecting all terminal activities.
/// 4. **Initial Marking**: Seeds the `start` place with a single token.
pub fn discover_him(admitted: &AdmittedReceipt) -> Result<PetriNet, MiningError> {
// Stage 1: Projection to OCEL
let ocel = receipt_to_ocel(&admitted.value)?;
let mut activities = HashSet::new();
let mut transitions = Vec::new();
let mut places = Vec::new();
let mut arcs = Vec::new();
// Stage 2: Transition Discovery (AC-1.9)
// We iterate through the event set and create one transition per activity type.
for ev in ocel.event_set() {
if activities.insert(ev.event_type.clone()) {
transitions.push(Transition::new(&ev.event_type, &ev.event_type));
}
}
// Ensure deterministic output by sorting transitions.
transitions.sort_by(|a, b| a.id().cmp(b.id()));
// Stage 3: Node/Transition Discovery Logic (Handover & Causality)
let mut object_traces: HashMap<String, Vec<String>> = HashMap::new();
let mut global_trace: Vec<String> = Vec::new();
// Trace discovery: populate traces from the OCEL log.
for ev in ocel.event_set() {
global_trace.push(ev.event_type.clone());
for (obj_id, _) in ocel.e2o(&ev.id) {
object_traces
.entry(obj_id.to_string())
.or_default()
.push(ev.event_type.clone());
}
}
let mut causal_pairs = HashSet::new();
let mut starts = HashSet::new();
let mut ends = HashSet::new();
// Extract causal relations from individual object lifecycles.
for trace in object_traces.values() {
if let Some(first) = trace.first() {
starts.insert(first.clone());
}
if let Some(last) = trace.last() {
ends.insert(last.clone());
}
for window in trace.windows(2) {
causal_pairs.insert((window[0].clone(), window[1].clone()));
}
}
// Fallback/Augmentation: use global trace to ensure the net is fully connected
// even if some events lack object associations.
if let Some(first) = global_trace.first() {
starts.insert(first.clone());
}
if let Some(last) = global_trace.last() {
ends.insert(last.clone());
}
for window in global_trace.windows(2) {
causal_pairs.insert((window[0].clone(), window[1].clone()));
}
// Stage 4: Synthesis of Places and Arcs.
// Start Place -> Initial Transitions
places.push(Place::new("start"));
for start_act in sorted_set(starts) {
arcs.push(Arc::place_to_transition("start", &start_act));
}
// Intermediate Causal Places
for (a, b) in sorted_pairs(causal_pairs) {
let place_id = format!("p_{}_{}", a, b);
places.push(Place::new(&place_id));
arcs.push(Arc::transition_to_place(&a, &place_id));
arcs.push(Arc::place_to_transition(&place_id, &b));
}
// Final Transitions -> End Place
places.push(Place::new("end"));
for end_act in sorted_set(ends) {
arcs.push(Arc::transition_to_place(&end_act, "end"));
}
// Stage 5: Initial Marking
let marking = Marking::new([("start".to_string(), 1)]);
// Construct the final Petri net.
Ok(PetriNet::new(places, transitions, arcs, marking))
}
/// Helper to sort a set of strings for deterministic processing.
fn sorted_set(set: HashSet<String>) -> Vec<String> {
let mut v: Vec<_> = set.into_iter().collect();
v.sort();
v
}
/// Helper to sort a set of activity pairs for deterministic processing.
fn sorted_pairs(set: HashSet<(String, String)>) -> Vec<(String, String)> {
let mut v: Vec<_> = set.into_iter().collect();
v.sort();
v
}