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;