Skip to main content

wasm4pm_compat/
eventlog.rs

1//! Case-centric event-log grammar — the classical process-mining shape.
2//!
3//! This module captures the **case-centric** view of process evidence: an
4//! [`crate::eventlog::Event`] is a single recorded activity occurrence; a [`crate::eventlog::Trace`] is the
5//! ordered, case-scoped sequence of those events; an [`crate::eventlog::EventLog`] is a
6//! collection of traces; an [`crate::eventlog::EventStream`] is the unbounded, append-only
7//! sibling of a log.
8//!
9//! This is the *single-case-notion* world (one case id per trace). The richer
10//! many-objects-per-event world lives in [`crate::ocel`] and is **not** modeled
11//! as "event log plus extras" — it is genuinely first-class there.
12//!
13//! ## Structure only
14//!
15//! These types are **shapes**, not engines. Nothing here discovers a model,
16//! replays a token, aligns a trace, or computes a fitness number. The only
17//! judgments offered are *structural*: is a trace empty, are its events
18//! time-monotonic, does an event name an activity. Anything that mines, scores,
19//! or executes belongs in `wasm4pm` — see "Graduation" below.
20//!
21//! ## Refusal is first-class
22//!
23//! Structural defects are reported through [`crate::eventlog::EventLogRefusal`], a *specifically
24//! named* law per defect — never a bare "invalid input".
25//!
26//! ## Graduation to `wasm4pm`
27//!
28//! An [`crate::eventlog::EventLog`] validated here is an admitted *substrate*. Discovery
29//! (Alpha/Inductive/Heuristic), conformance checking, variant analysis, and
30//! performance mining all graduate to the `wasm4pm` execution engine. This
31//! crate only guarantees the log is *well-shaped enough to mine*.
32
33/// A single recorded activity occurrence within one case.
34///
35/// An `Event` represents *what happened* (the activity name), *when* (an
36/// optional nanosecond timestamp), and *by whom* (an optional resource). It is
37/// deliberately small and transparent.
38///
39/// This type does **not** represent an OCEL event (many objects per event) —
40/// see [`crate::ocel::OcelEvent`] for that. It is structure-only: it carries no
41/// behavior beyond construction and accessors, and graduates to `wasm4pm` when
42/// it participates in discovery or replay.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct Event {
45    activity: String,
46    /// Optional event timestamp in nanoseconds since the Unix epoch.
47    timestamp_ns: Option<i64>,
48    /// Optional originating resource (operator, system, role).
49    resource: Option<String>,
50    /// Optional XES-style lifecycle transition (e.g. `start`, `complete`).
51    lifecycle: Option<String>,
52}
53
54impl Event {
55    /// Construct an event naming an activity, with no timestamp or resource.
56    ///
57    /// ```
58    /// use wasm4pm_compat::eventlog::Event;
59    /// let e = Event::new("place_order");
60    /// assert_eq!(e.activity(), "place_order");
61    /// assert!(e.timestamp_ns().is_none());
62    /// ```
63    pub fn new(activity: impl Into<String>) -> Self {
64        Event {
65            activity: activity.into(),
66            timestamp_ns: None,
67            resource: None,
68            lifecycle: None,
69        }
70    }
71
72    /// Attach a nanosecond timestamp (Unix epoch). Builder-style.
73    ///
74    /// ```
75    /// use wasm4pm_compat::eventlog::Event;
76    /// let e = Event::new("ship").at_ns(1_700_000_000_000_000_000);
77    /// assert_eq!(e.timestamp_ns(), Some(1_700_000_000_000_000_000));
78    /// ```
79    pub fn at_ns(mut self, ts: i64) -> Self {
80        self.timestamp_ns = Some(ts);
81        self
82    }
83
84    /// Attach an originating resource. Builder-style.
85    ///
86    /// ```
87    /// use wasm4pm_compat::eventlog::Event;
88    /// let e = Event::new("approve").by("alice");
89    /// assert_eq!(e.resource(), Some("alice"));
90    /// ```
91    pub fn by(mut self, resource: impl Into<String>) -> Self {
92        self.resource = Some(resource.into());
93        self
94    }
95
96    /// Attach a lifecycle transition (e.g. `start`/`complete`). Builder-style.
97    ///
98    /// ```
99    /// use wasm4pm_compat::eventlog::Event;
100    /// let e = Event::new("pack").with_lifecycle("start");
101    /// assert_eq!(e.lifecycle(), Some("start"));
102    /// ```
103    pub fn with_lifecycle(mut self, transition: impl Into<String>) -> Self {
104        self.lifecycle = Some(transition.into());
105        self
106    }
107
108    /// The activity name. Empty names are refused at trace validation time.
109    ///
110    /// ```
111    /// use wasm4pm_compat::eventlog::Event;
112    /// assert_eq!(Event::new("ship").activity(), "ship");
113    /// ```
114    pub fn activity(&self) -> &str {
115        &self.activity
116    }
117
118    /// The optional timestamp in nanoseconds since the Unix epoch.
119    ///
120    /// ```
121    /// use wasm4pm_compat::eventlog::Event;
122    /// assert_eq!(Event::new("x").timestamp_ns(), None);
123    /// ```
124    #[must_use]
125    pub fn timestamp_ns(&self) -> Option<i64> {
126        self.timestamp_ns
127    }
128
129    /// The optional originating resource.
130    ///
131    /// ```
132    /// use wasm4pm_compat::eventlog::Event;
133    /// assert_eq!(Event::new("x").resource(), None);
134    /// ```
135    #[must_use]
136    pub fn resource(&self) -> Option<&str> {
137        self.resource.as_deref()
138    }
139
140    /// The optional lifecycle transition.
141    ///
142    /// ```
143    /// use wasm4pm_compat::eventlog::Event;
144    /// assert_eq!(Event::new("x").lifecycle(), None);
145    /// ```
146    #[must_use]
147    pub fn lifecycle(&self) -> Option<&str> {
148        self.lifecycle.as_deref()
149    }
150}
151
152/// An ordered, case-scoped sequence of [`Event`]s.
153///
154/// A `Trace` is one process instance: a case id plus the events recorded for
155/// it, in order. The order is *the data* — this crate does not re-sort or infer
156/// it.
157///
158/// This is structure-only: [`Trace::validate`] checks structural laws but does
159/// **not** mine the trace. It graduates to `wasm4pm` for variant analysis,
160/// alignment, and replay.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub struct Trace {
163    case_id: String,
164    events: Vec<Event>,
165}
166
167impl Trace {
168    /// Construct a trace from a case id and an iterator of events.
169    ///
170    /// ```
171    /// use wasm4pm_compat::eventlog::{Event, Trace};
172    /// let t = Trace::new("case-1", [Event::new("a"), Event::new("b")]);
173    /// assert_eq!(t.len(), 2);
174    /// assert_eq!(t.case_id(), "case-1");
175    /// ```
176    pub fn new(case_id: impl Into<String>, events: impl IntoIterator<Item = Event>) -> Self {
177        Trace {
178            case_id: case_id.into(),
179            events: events.into_iter().collect(),
180        }
181    }
182
183    /// Construct a trace from events, assigning the placeholder case id `"_"`.
184    ///
185    /// Useful when the case identity is implied by context; the placeholder is
186    /// still a non-empty case id and so passes [`Trace::validate`]'s case check.
187    ///
188    /// ```
189    /// use wasm4pm_compat::eventlog::{Event, Trace};
190    /// let t = Trace::from_events([Event::new("a")]);
191    /// assert_eq!(t.case_id(), "_");
192    /// ```
193    pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
194        Trace::new("_", events)
195    }
196
197    /// The case identifier for this trace.
198    ///
199    /// ```
200    /// use wasm4pm_compat::eventlog::{Event, Trace};
201    /// assert_eq!(Trace::new("c", [Event::new("a")]).case_id(), "c");
202    /// ```
203    pub fn case_id(&self) -> &str {
204        &self.case_id
205    }
206
207    /// The ordered events of this trace.
208    ///
209    /// ```
210    /// use wasm4pm_compat::eventlog::{Event, Trace};
211    /// let t = Trace::new("c", [Event::new("a")]);
212    /// assert_eq!(t.events().len(), 1);
213    /// ```
214    pub fn events(&self) -> &[Event] {
215        &self.events
216    }
217
218    /// The number of events in this trace.
219    ///
220    /// ```
221    /// use wasm4pm_compat::eventlog::{Event, Trace};
222    /// assert_eq!(Trace::new("c", [Event::new("a")]).len(), 1);
223    /// ```
224    pub fn len(&self) -> usize {
225        self.events.len()
226    }
227
228    /// Whether this trace has no events.
229    ///
230    /// ```
231    /// use wasm4pm_compat::eventlog::Trace;
232    /// assert!(Trace::from_events([]).is_empty());
233    /// ```
234    pub fn is_empty(&self) -> bool {
235        self.events.is_empty()
236    }
237
238    /// Check the *structural* laws of a single trace.
239    ///
240    /// This validates, in order:
241    /// - case id is non-empty ([`EventLogRefusal::MissingCaseId`]);
242    /// - the trace has at least one event ([`EventLogRefusal::EmptyTrace`]);
243    /// - every event names a non-empty activity ([`EventLogRefusal::MissingActivity`]);
244    /// - timestamps, where present on consecutive events, are non-decreasing
245    ///   ([`EventLogRefusal::NonMonotonicTrace`]).
246    ///
247    /// It does **not** mine, score, or replay anything — this is a shape check.
248    ///
249    /// ```
250    /// use wasm4pm_compat::eventlog::{Event, Trace, EventLogRefusal};
251    /// let ok = Trace::new("c", [Event::new("a").at_ns(1), Event::new("b").at_ns(2)]);
252    /// assert!(ok.validate().is_ok());
253    ///
254    /// let bad = Trace::new("c", [Event::new("a").at_ns(5), Event::new("b").at_ns(1)]);
255    /// assert_eq!(bad.validate(), Err(EventLogRefusal::NonMonotonicTrace));
256    /// ```
257    #[must_use = "check the shape-check result"]
258    pub fn validate(&self) -> Result<(), EventLogRefusal> {
259        if self.case_id.is_empty() {
260            return Err(EventLogRefusal::MissingCaseId);
261        }
262        if self.events.is_empty() {
263            return Err(EventLogRefusal::EmptyTrace);
264        }
265        let mut last_ts: Option<i64> = None;
266        for ev in &self.events {
267            if ev.activity().is_empty() {
268                return Err(EventLogRefusal::MissingActivity);
269            }
270            if let Some(ts) = ev.timestamp_ns() {
271                if let Some(prev) = last_ts {
272                    if ts < prev {
273                        return Err(EventLogRefusal::NonMonotonicTrace);
274                    }
275                }
276                last_ts = Some(ts);
277            }
278        }
279        Ok(())
280    }
281}
282
283/// A collection of [`Trace`]s — the classical case-centric event log.
284///
285/// An `EventLog` is the substrate of discovery and conformance. This crate only
286/// represents and structurally validates it; the actual mining graduates to
287/// `wasm4pm`.
288///
289/// Structure-only: [`EventLog::validate`] runs each trace's structural checks
290/// but performs no analysis.
291#[derive(Clone, Debug, Default, PartialEq, Eq)]
292pub struct EventLog {
293    traces: Vec<Trace>,
294}
295
296impl EventLog {
297    /// Construct a log from an iterator of traces.
298    ///
299    /// ```
300    /// use wasm4pm_compat::eventlog::{Event, Trace, EventLog};
301    /// let log = EventLog::from_traces([Trace::new("c", [Event::new("a")])]);
302    /// assert_eq!(log.trace_count(), 1);
303    /// ```
304    pub fn from_traces(traces: impl IntoIterator<Item = Trace>) -> Self {
305        EventLog {
306            traces: traces.into_iter().collect(),
307        }
308    }
309
310    /// The traces of this log.
311    ///
312    /// ```
313    /// use wasm4pm_compat::eventlog::EventLog;
314    /// assert!(EventLog::default().traces().is_empty());
315    /// ```
316    pub fn traces(&self) -> &[Trace] {
317        &self.traces
318    }
319
320    /// The number of traces in this log.
321    ///
322    /// ```
323    /// use wasm4pm_compat::eventlog::{Event, Trace, EventLog};
324    /// let log = EventLog::from_traces([Trace::new("c", [Event::new("a")])]);
325    /// assert_eq!(log.trace_count(), 1);
326    /// ```
327    pub fn trace_count(&self) -> usize {
328        self.traces.len()
329    }
330
331    /// The total number of events across all traces.
332    ///
333    /// ```
334    /// use wasm4pm_compat::eventlog::{Event, Trace, EventLog};
335    /// let log = EventLog::from_traces([
336    ///     Trace::new("c1", [Event::new("a"), Event::new("b")]),
337    ///     Trace::new("c2", [Event::new("a")]),
338    /// ]);
339    /// assert_eq!(log.event_count(), 3);
340    /// ```
341    pub fn event_count(&self) -> usize {
342        self.traces.iter().map(Trace::len).sum()
343    }
344
345    /// Validate every trace structurally, returning the first refusal.
346    ///
347    /// This is a shape check across the whole log; it does not discover or
348    /// score anything. For mining, graduate to `wasm4pm`.
349    ///
350    /// ```
351    /// use wasm4pm_compat::eventlog::{Trace, EventLog, EventLogRefusal};
352    /// let bad = EventLog::from_traces([Trace::from_events([])]);
353    /// assert_eq!(bad.validate(), Err(EventLogRefusal::EmptyTrace));
354    /// ```
355    #[must_use = "check the shape-check result"]
356    pub fn validate(&self) -> Result<(), EventLogRefusal> {
357        for t in &self.traces {
358            t.validate()?;
359        }
360        Ok(())
361    }
362}
363
364// ── IntoIterator for EventLog ─────────────────────────────────────────────────
365
366impl<'a> IntoIterator for &'a EventLog {
367    type Item = &'a Trace;
368    type IntoIter = core::slice::Iter<'a, Trace>;
369
370    /// Iterate over the [`Trace`]s of this log.
371    ///
372    /// This makes `EventLog` idiomatic to use in `for` loops and iterator
373    /// chains without a separate `.traces()` call:
374    ///
375    /// ```
376    /// use wasm4pm_compat::eventlog::{Event, Trace, EventLog};
377    /// let log = EventLog::from_traces([
378    ///     Trace::new("c1", [Event::new("a")]),
379    ///     Trace::new("c2", [Event::new("b")]),
380    /// ]);
381    /// let cases: Vec<&str> = (&log).into_iter().map(|t| t.case_id()).collect();
382    /// assert_eq!(cases, ["c1", "c2"]);
383    /// ```
384    fn into_iter(self) -> Self::IntoIter {
385        self.traces.iter()
386    }
387}
388
389impl IntoIterator for EventLog {
390    type Item = Trace;
391    type IntoIter = std::vec::IntoIter<Trace>;
392
393    /// Consume the log and iterate over its [`Trace`]s by value.
394    ///
395    /// ```
396    /// use wasm4pm_compat::eventlog::{Event, Trace, EventLog};
397    /// let log = EventLog::from_traces([Trace::new("c", [Event::new("a")])]);
398    /// let v: Vec<Trace> = log.into_iter().collect();
399    /// assert_eq!(v.len(), 1);
400    /// ```
401    fn into_iter(self) -> Self::IntoIter {
402        self.traces.into_iter()
403    }
404}
405
406/// An append-only, potentially unbounded stream of [`Event`]s.
407///
408/// An `EventStream` is the online sibling of an [`EventLog`]: events arrive over
409/// time and are buffered in arrival order. This crate models the *shape* of a
410/// stream buffer only; streaming discovery and online conformance graduate to
411/// `wasm4pm`.
412///
413/// Structure-only: pushing an event records it; nothing is mined.
414#[derive(Clone, Debug, Default, PartialEq, Eq)]
415pub struct EventStream {
416    buffered: Vec<Event>,
417}
418
419impl EventStream {
420    /// Construct an empty stream buffer.
421    ///
422    /// ```
423    /// use wasm4pm_compat::eventlog::EventStream;
424    /// assert_eq!(EventStream::new().len(), 0);
425    /// ```
426    pub fn new() -> Self {
427        EventStream::default()
428    }
429
430    /// Append an event to the stream buffer, in arrival order.
431    ///
432    /// ```
433    /// use wasm4pm_compat::eventlog::{Event, EventStream};
434    /// let mut s = EventStream::new();
435    /// s.push(Event::new("a"));
436    /// assert_eq!(s.len(), 1);
437    /// ```
438    pub fn push(&mut self, event: Event) {
439        self.buffered.push(event);
440    }
441
442    /// The buffered events, in arrival order.
443    ///
444    /// ```
445    /// use wasm4pm_compat::eventlog::{Event, EventStream};
446    /// let mut s = EventStream::new();
447    /// s.push(Event::new("a"));
448    /// assert_eq!(s.buffered()[0].activity(), "a");
449    /// ```
450    pub fn buffered(&self) -> &[Event] {
451        &self.buffered
452    }
453
454    /// The number of buffered events.
455    ///
456    /// ```
457    /// use wasm4pm_compat::eventlog::EventStream;
458    /// assert_eq!(EventStream::new().len(), 0);
459    /// ```
460    pub fn len(&self) -> usize {
461        self.buffered.len()
462    }
463
464    /// Whether the stream buffer is empty.
465    ///
466    /// ```
467    /// use wasm4pm_compat::eventlog::EventStream;
468    /// assert!(EventStream::new().is_empty());
469    /// ```
470    pub fn is_empty(&self) -> bool {
471        self.buffered.is_empty()
472    }
473}
474
475/// A classifier declaration from a XES log header — names the key(s) that identify activity.
476///
477/// XES logs may declare one or more classifiers in their header. Each classifier
478/// names an activity-identification strategy: the `keys` list the XES attribute
479/// keys whose concatenated values form the activity label for that classifier.
480/// The most common classifier is `{"name": "concept:name", "keys": ["concept:name"]}`.
481///
482/// This is structural metadata about the log schema; it carries no judgment and
483/// is not Evidence-wrapped.
484#[derive(Clone, Debug, PartialEq, Eq)]
485pub struct EventLogClassifier {
486    /// Human-readable name for this classifier (e.g. `"concept:name"`).
487    pub name: String,
488    /// Ordered list of XES attribute keys whose values form the activity label.
489    pub keys: Vec<String>,
490}
491
492impl EventLogClassifier {
493    /// Construct a classifier from a name and an iterator of keys.
494    ///
495    /// ```
496    /// use wasm4pm_compat::eventlog::EventLogClassifier;
497    /// let c = EventLogClassifier::new("concept:name", ["concept:name"]);
498    /// assert_eq!(c.name, "concept:name");
499    /// assert_eq!(c.keys, ["concept:name"]);
500    /// ```
501    pub fn new(name: impl Into<String>, keys: impl IntoIterator<Item = impl Into<String>>) -> Self {
502        EventLogClassifier {
503            name: name.into(),
504            keys: keys.into_iter().map(Into::into).collect(),
505        }
506    }
507}
508
509/// The specific, named laws under which case-centric event-log structure is
510/// refused.
511///
512/// Each variant is a *distinct law* with a meaning auditors can cite — never a
513/// catch-all "invalid input". These describe structural defects only; they say
514/// nothing about model quality (that is a `wasm4pm` concern).
515#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
516#[non_exhaustive]
517pub enum EventLogRefusal {
518    /// A trace carries an empty case identifier.
519    MissingCaseId,
520    /// An event carries an empty activity name.
521    MissingActivity,
522    /// A timestamp was required by policy but absent.
523    MissingTimestamp,
524    /// A trace contains no events.
525    EmptyTrace,
526    /// Consecutive timestamps decrease — the trace is not time-monotonic.
527    NonMonotonicTrace,
528    /// The same event occurs twice where uniqueness was required.
529    DuplicateEvent,
530    /// A lifecycle transition is malformed or out of its declared alphabet.
531    InvalidLifecycle,
532}
533
534impl core::fmt::Display for EventLogRefusal {
535    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
536        let law = match self {
537            EventLogRefusal::MissingCaseId => "MissingCaseId",
538            EventLogRefusal::MissingActivity => "MissingActivity",
539            EventLogRefusal::MissingTimestamp => "MissingTimestamp",
540            EventLogRefusal::EmptyTrace => "EmptyTrace",
541            EventLogRefusal::NonMonotonicTrace => "NonMonotonicTrace",
542            EventLogRefusal::DuplicateEvent => "DuplicateEvent",
543            EventLogRefusal::InvalidLifecycle => "InvalidLifecycle",
544        };
545        write!(f, "event-log refused by law: {law}")
546    }
547}
548
549// ── Structural bridge conversions ─────────────────────────────────────────────
550
551impl From<crate::ocel::OcelEvent> for Event {
552    /// Converts an [`OcelEvent`](crate::ocel::OcelEvent) to a case-centric [`Event`].
553    ///
554    /// Maps the OCEL event's `activity` to [`Event::activity`] and its
555    /// `timestamp_ns` if present.
556    ///
557    /// **Loss**: all OCEL-specific context — object links (E2O), typed
558    /// attributes, and the event id string — is dropped. This conversion is
559    /// a structural bridge for quick ergonomic interop; when loss must be
560    /// accounted for, use [`crate::loss::Project`] with a named
561    /// [`crate::loss::LossPolicy`] instead.
562    ///
563    /// # Examples
564    ///
565    /// ```
566    /// use wasm4pm_compat::ocel::OcelEvent;
567    /// use wasm4pm_compat::eventlog::Event;
568    /// let oe = OcelEvent::new("e1", "place_order").at_ns(42);
569    /// let e = Event::from(oe);
570    /// assert_eq!(e.activity(), "place_order");
571    /// assert_eq!(e.timestamp_ns(), Some(42));
572    /// ```
573    fn from(ocel_event: crate::ocel::OcelEvent) -> Self {
574        let mut ev = Event::new(ocel_event.activity().to_owned());
575        if let Some(ts) = ocel_event.timestamp_ns() {
576            ev = ev.at_ns(ts);
577        }
578        ev
579    }
580}
581
582impl From<crate::xes::XesEvent> for Event {
583    /// Converts a [`XesEvent`](crate::xes::XesEvent) to a case-centric [`Event`].
584    ///
585    /// Uses `concept:name` as the activity. Copies `time:timestamp` (as
586    /// nanoseconds if parseable as `i64`), `org:resource`, and
587    /// `lifecycle:transition` to the corresponding [`Event`] fields.
588    ///
589    /// **Loss**: all non-standard XES attributes are dropped. If `concept:name`
590    /// is absent the activity defaults to an empty string (which will be refused
591    /// at [`Trace::validate`] time). For loss-accountable projection use
592    /// [`crate::loss::Project`] with a named [`crate::loss::LossPolicy`].
593    ///
594    /// # Examples
595    ///
596    /// ```
597    /// use wasm4pm_compat::xes::XesEvent;
598    /// use wasm4pm_compat::eventlog::Event;
599    /// let xe = XesEvent::new()
600    ///     .with("concept:name", "approve")
601    ///     .with("org:resource", "alice");
602    /// let e = Event::from(xe);
603    /// assert_eq!(e.activity(), "approve");
604    /// assert_eq!(e.resource(), Some("alice"));
605    /// ```
606    fn from(xes_event: crate::xes::XesEvent) -> Self {
607        let activity = xes_event.concept_name().unwrap_or("").to_owned();
608        let mut ev = Event::new(activity);
609        // time:timestamp — attempt to parse as i64 nanoseconds.
610        if let Some(ts_str) = xes_event.attribute("time:timestamp") {
611            if let Ok(ts) = ts_str.parse::<i64>() {
612                ev = ev.at_ns(ts);
613            }
614        }
615        if let Some(res) = xes_event.attribute("org:resource") {
616            ev = ev.by(res.to_owned());
617        }
618        if let Some(lc) = xes_event.attribute("lifecycle:transition") {
619            ev = ev.with_lifecycle(lc.to_owned());
620        }
621        ev
622    }
623}
624
625/// A XES extension declaration — name, prefix, and namespace URI.
626///
627/// This is a type alias for [`crate::xes::XesExtension`], which carries the
628/// same three fields. It mirrors the `EventLogExtension` shape in
629/// `wasm4pm-types` so that compat achieves superset coverage.
630pub type EventLogExtension = crate::xes::XesExtension;