wasm4pm_compat/ocel.rs
1//! Object-Centric Event Log (OCEL) shape — **first-class**, not "event log plus
2//! extras".
3//!
4//! Classical event logs assume a *single case notion*: every event belongs to
5//! exactly one case. OCEL drops that assumption: an event may relate to *many*
6//! objects of *many* types, and objects relate to each other and change over
7//! time. Modeling OCEL as "an [`crate::eventlog::EventLog`] with side tables"
8//! would be a category error — so this module gives OCEL its own genuine canon:
9//! [`crate::ocel::OcelObject`], [`crate::ocel::OcelEvent`], [`crate::ocel::EventObjectLink`] (E2O), [`crate::ocel::ObjectObjectLink`]
10//! (O2O), and [`crate::ocel::ObjectChange`], collected in an [`crate::ocel::OcelLog`].
11//!
12//! ## Structure only
13//!
14//! [`crate::ocel::OcelLog::validate`] performs a **structural** integrity check: every link
15//! must reference declared objects and events; ids must be unique. It does
16//! **not** discover an object-centric Petri net, flatten the log, or check
17//! conformance — those are engines and graduate to `wasm4pm`.
18//!
19//! ## The flattening trap
20//!
21//! Flattening OCEL to a single case notion is *lossy by construction* — it
22//! drops convergence/divergence information. This crate treats that as a named
23//! law, [`crate::ocel::OcelRefusal::FlatteningLoss`], so a flattening projection must carry a
24//! loss policy and report rather than silently laundering object-centric truth
25//! into case-centric shape.
26//!
27//! ## Graduation to `wasm4pm`
28//!
29//! Object-centric discovery (OC-Petri-nets, OC-DFG), object-centric conformance,
30//! and OCPQ evaluation graduate to `wasm4pm`. This crate only guarantees the
31//! OCEL is *well-shaped enough to mine*.
32
33use std::collections::HashSet;
34
35/// A typed attribute value on an [`OcelObject`] or [`OcelEvent`].
36///
37/// OCEL 2.0 attributes are typed: the key is a `&str`-named field and the value
38/// is one of string, integer, float, boolean, timestamp, list, or map.
39/// `OcelAttributeValue` models the value side; the key is always a `String`.
40///
41/// The `List` and `Map` variants reflect the OCEL 2.0 spec allowance for nested
42/// attribute structures (e.g. a list of prices, a map of sub-attributes). Both
43/// are structure-only containers — no evaluation or indexing occurs here; that
44/// graduates to `wasm4pm`.
45///
46/// Structure-only: it stores the value, not a parser.
47#[derive(Clone, Debug, PartialEq)]
48pub enum OcelAttributeValue {
49 /// A string-valued attribute.
50 String(String),
51 /// An integer-valued attribute.
52 Integer(i64),
53 /// A floating-point attribute.
54 Float(f64),
55 /// A boolean attribute.
56 Boolean(bool),
57 /// A timestamp attribute as nanoseconds since the Unix epoch.
58 TimestampNs(i64),
59 /// An ordered list of attribute values (OCEL 2.0 nested list).
60 ///
61 /// Structure-only: it is a container, not an indexed collection. Indexing
62 /// and aggregation graduate to `wasm4pm`.
63 List(Vec<OcelAttributeValue>),
64 /// A key/value map of attribute values (OCEL 2.0 nested map).
65 ///
66 /// Structure-only: it is a named container, not a record system. Lookup
67 /// and projection graduate to `wasm4pm`.
68 Map(Vec<(String, OcelAttributeValue)>),
69}
70
71/// A named attribute (key + value) on an [`OcelObject`] or [`OcelEvent`].
72///
73/// Structure-only: it is a key/value pair, not a mined feature.
74#[derive(Clone, Debug, PartialEq)]
75pub struct OcelAttribute {
76 /// The attribute key.
77 pub key: String,
78 /// The typed attribute value.
79 pub value: OcelAttributeValue,
80}
81
82impl OcelAttribute {
83 /// Construct a string attribute.
84 ///
85 /// ```
86 /// use wasm4pm_compat::ocel::{OcelAttribute, OcelAttributeValue};
87 /// let a = OcelAttribute::string("status", "open");
88 /// assert_eq!(a.key, "status");
89 /// assert_eq!(a.value, OcelAttributeValue::String("open".into()));
90 /// ```
91 pub fn string(key: impl Into<String>, value: impl Into<String>) -> Self {
92 OcelAttribute {
93 key: key.into(),
94 value: OcelAttributeValue::String(value.into()),
95 }
96 }
97
98 /// Construct an integer attribute.
99 ///
100 /// ```
101 /// use wasm4pm_compat::ocel::{OcelAttribute, OcelAttributeValue};
102 /// let a = OcelAttribute::integer("quantity", 3);
103 /// assert_eq!(a.value, OcelAttributeValue::Integer(3));
104 /// ```
105 pub fn integer(key: impl Into<String>, value: i64) -> Self {
106 OcelAttribute {
107 key: key.into(),
108 value: OcelAttributeValue::Integer(value),
109 }
110 }
111
112 /// Construct a float attribute.
113 ///
114 /// ```
115 /// use wasm4pm_compat::ocel::{OcelAttribute, OcelAttributeValue};
116 /// let a = OcelAttribute::float("price", 9.99);
117 /// assert_eq!(a.value, OcelAttributeValue::Float(9.99));
118 /// ```
119 pub fn float(key: impl Into<String>, value: f64) -> Self {
120 OcelAttribute {
121 key: key.into(),
122 value: OcelAttributeValue::Float(value),
123 }
124 }
125
126 /// Construct a boolean attribute.
127 ///
128 /// ```
129 /// use wasm4pm_compat::ocel::{OcelAttribute, OcelAttributeValue};
130 /// let a = OcelAttribute::boolean("active", true);
131 /// assert_eq!(a.value, OcelAttributeValue::Boolean(true));
132 /// ```
133 pub fn boolean(key: impl Into<String>, value: bool) -> Self {
134 OcelAttribute {
135 key: key.into(),
136 value: OcelAttributeValue::Boolean(value),
137 }
138 }
139
140 /// Construct a nanosecond-timestamp attribute.
141 ///
142 /// ```
143 /// use wasm4pm_compat::ocel::{OcelAttribute, OcelAttributeValue};
144 /// let a = OcelAttribute::timestamp_ns("created_at", 1_700_000_000_000_000_000);
145 /// assert_eq!(a.value, OcelAttributeValue::TimestampNs(1_700_000_000_000_000_000));
146 /// ```
147 pub fn timestamp_ns(key: impl Into<String>, value: i64) -> Self {
148 OcelAttribute {
149 key: key.into(),
150 value: OcelAttributeValue::TimestampNs(value),
151 }
152 }
153}
154
155/// An object: a typed, identified entity that events relate to, with OCEL 2.0
156/// attributes.
157///
158/// In OCEL an object (e.g. an order, an item, a delivery) has a stable id, a
159/// type, and a bag of typed attributes at creation time. Attribute evolution over
160/// time is captured in [`ObjectChange`]. Multiple events may touch the same
161/// object, and objects relate to one another via [`ObjectObjectLink`]s.
162///
163/// Structure-only: an `OcelObject` records identity, type, and initial
164/// attributes; it does not compute object behavior. That graduates to `wasm4pm`.
165#[derive(Clone, Debug, PartialEq)]
166pub struct OcelObject {
167 id: String,
168 object_type: String,
169 attributes: Vec<OcelAttribute>,
170}
171
172impl OcelObject {
173 /// Construct an object with an id and a type, with no attributes.
174 ///
175 /// ```
176 /// use wasm4pm_compat::ocel::OcelObject;
177 /// let o = OcelObject::new("ord-1", "order");
178 /// assert_eq!(o.id(), "ord-1");
179 /// assert_eq!(o.object_type(), "order");
180 /// assert!(o.attributes().is_empty());
181 /// ```
182 pub fn new(id: impl Into<String>, object_type: impl Into<String>) -> Self {
183 OcelObject {
184 id: id.into(),
185 object_type: object_type.into(),
186 attributes: Vec::new(),
187 }
188 }
189
190 /// Attach an attribute. Builder-style.
191 ///
192 /// ```
193 /// use wasm4pm_compat::ocel::{OcelObject, OcelAttribute};
194 /// let o = OcelObject::new("ord-1", "order").with_attribute(OcelAttribute::string("status", "open"));
195 /// assert_eq!(o.attributes().len(), 1);
196 /// ```
197 pub fn with_attribute(mut self, attr: OcelAttribute) -> Self {
198 self.attributes.push(attr);
199 self
200 }
201
202 /// The stable object identifier.
203 ///
204 /// ```
205 /// use wasm4pm_compat::ocel::OcelObject;
206 /// assert_eq!(OcelObject::new("x", "t").id(), "x");
207 /// ```
208 pub fn id(&self) -> &str {
209 &self.id
210 }
211
212 /// The object type (empty types are refused at validation time).
213 ///
214 /// ```
215 /// use wasm4pm_compat::ocel::OcelObject;
216 /// assert_eq!(OcelObject::new("x", "t").object_type(), "t");
217 /// ```
218 pub fn object_type(&self) -> &str {
219 &self.object_type
220 }
221
222 /// The initial attributes of this object.
223 ///
224 /// ```
225 /// use wasm4pm_compat::ocel::OcelObject;
226 /// assert!(OcelObject::new("x", "t").attributes().is_empty());
227 /// ```
228 pub fn attributes(&self) -> &[OcelAttribute] {
229 &self.attributes
230 }
231}
232
233/// A backwards-compatible alias for [`OcelObject`].
234///
235/// Existing code using `Object` continues to compile. New code should prefer
236/// [`OcelObject`].
237#[deprecated(
238 since = "26.6.5",
239 note = "use `OcelObject` — the unambiguous name for the OCEL object shape"
240)]
241pub type Object = OcelObject;
242
243/// An object-centric event: an identified, named activity occurrence that may
244/// relate to many objects, with OCEL 2.0 typed attributes.
245///
246/// Named `OcelEvent` (not `Event`) to stand clearly apart from the case-centric
247/// [`crate::eventlog::Event`]: an `OcelEvent` carries no single case id, because
248/// in OCEL there is no single case notion. Its object relationships live in the
249/// [`OcelLog`]'s [`EventObjectLink`] table.
250///
251/// Structure-only: it records id, activity, optional time, and attributes; it
252/// does not replay or mine.
253#[derive(Clone, Debug, PartialEq)]
254pub struct OcelEvent {
255 id: String,
256 activity: String,
257 timestamp_ns: Option<i64>,
258 attributes: Vec<OcelAttribute>,
259}
260
261impl OcelEvent {
262 /// Construct an OCEL event with an id and activity name.
263 ///
264 /// ```
265 /// use wasm4pm_compat::ocel::OcelEvent;
266 /// let e = OcelEvent::new("e1", "place_order");
267 /// assert_eq!(e.id(), "e1");
268 /// assert_eq!(e.activity(), "place_order");
269 /// assert!(e.attributes().is_empty());
270 /// ```
271 pub fn new(id: impl Into<String>, activity: impl Into<String>) -> Self {
272 OcelEvent {
273 id: id.into(),
274 activity: activity.into(),
275 timestamp_ns: None,
276 attributes: Vec::new(),
277 }
278 }
279
280 /// Attach an attribute. Builder-style.
281 ///
282 /// ```
283 /// use wasm4pm_compat::ocel::{OcelEvent, OcelAttribute};
284 /// let e = OcelEvent::new("e1", "ship").with_attribute(OcelAttribute::string("channel", "web"));
285 /// assert_eq!(e.attributes().len(), 1);
286 /// ```
287 pub fn with_attribute(mut self, attr: OcelAttribute) -> Self {
288 self.attributes.push(attr);
289 self
290 }
291
292 /// The event attributes.
293 ///
294 /// ```
295 /// use wasm4pm_compat::ocel::OcelEvent;
296 /// assert!(OcelEvent::new("e1", "a").attributes().is_empty());
297 /// ```
298 pub fn attributes(&self) -> &[OcelAttribute] {
299 &self.attributes
300 }
301
302 /// Attach a nanosecond timestamp. Builder-style.
303 ///
304 /// ```
305 /// use wasm4pm_compat::ocel::OcelEvent;
306 /// let e = OcelEvent::new("e1", "ship").at_ns(42);
307 /// assert_eq!(e.timestamp_ns(), Some(42));
308 /// ```
309 pub fn at_ns(mut self, ts: i64) -> Self {
310 self.timestamp_ns = Some(ts);
311 self
312 }
313
314 /// The stable event identifier.
315 ///
316 /// ```
317 /// use wasm4pm_compat::ocel::OcelEvent;
318 /// assert_eq!(OcelEvent::new("e1", "a").id(), "e1");
319 /// ```
320 pub fn id(&self) -> &str {
321 &self.id
322 }
323
324 /// The activity name.
325 ///
326 /// ```
327 /// use wasm4pm_compat::ocel::OcelEvent;
328 /// assert_eq!(OcelEvent::new("e1", "a").activity(), "a");
329 /// ```
330 pub fn activity(&self) -> &str {
331 &self.activity
332 }
333
334 /// The optional timestamp in nanoseconds since the Unix epoch.
335 ///
336 /// ```
337 /// use wasm4pm_compat::ocel::OcelEvent;
338 /// assert_eq!(OcelEvent::new("e1", "a").timestamp_ns(), None);
339 /// ```
340 #[must_use]
341 pub fn timestamp_ns(&self) -> Option<i64> {
342 self.timestamp_ns
343 }
344}
345
346/// An event-to-object (E2O) link: which objects an event touched, and how.
347///
348/// The optional `qualifier` names the *role* of the object in the event (e.g.
349/// `"item"`, `"customer"`). A dangling link — one pointing at an undeclared
350/// event or object — is a structural defect, refused as
351/// [`OcelRefusal::DanglingEventObjectLink`].
352///
353/// Structure-only: it is a typed edge in the OCEL graph, not a mined relation.
354#[derive(Clone, Debug, PartialEq, Eq)]
355pub struct EventObjectLink {
356 event_id: String,
357 object_id: String,
358 qualifier: Option<String>,
359}
360
361impl EventObjectLink {
362 /// Construct an unqualified E2O link.
363 ///
364 /// ```
365 /// use wasm4pm_compat::ocel::EventObjectLink;
366 /// let l = EventObjectLink::new("e1", "ord-1");
367 /// assert_eq!(l.event_id(), "e1");
368 /// assert_eq!(l.object_id(), "ord-1");
369 /// ```
370 pub fn new(event_id: impl Into<String>, object_id: impl Into<String>) -> Self {
371 EventObjectLink {
372 event_id: event_id.into(),
373 object_id: object_id.into(),
374 qualifier: None,
375 }
376 }
377
378 /// Attach a role qualifier. Builder-style.
379 ///
380 /// ```
381 /// use wasm4pm_compat::ocel::EventObjectLink;
382 /// let l = EventObjectLink::new("e1", "ord-1").qualified("places");
383 /// assert_eq!(l.qualifier(), Some("places"));
384 /// ```
385 pub fn qualified(mut self, qualifier: impl Into<String>) -> Self {
386 self.qualifier = Some(qualifier.into());
387 self
388 }
389
390 /// The referenced event id.
391 ///
392 /// ```
393 /// use wasm4pm_compat::ocel::EventObjectLink;
394 /// assert_eq!(EventObjectLink::new("e", "o").event_id(), "e");
395 /// ```
396 pub fn event_id(&self) -> &str {
397 &self.event_id
398 }
399
400 /// The referenced object id.
401 ///
402 /// ```
403 /// use wasm4pm_compat::ocel::EventObjectLink;
404 /// assert_eq!(EventObjectLink::new("e", "o").object_id(), "o");
405 /// ```
406 pub fn object_id(&self) -> &str {
407 &self.object_id
408 }
409
410 /// The optional role qualifier.
411 ///
412 /// ```
413 /// use wasm4pm_compat::ocel::EventObjectLink;
414 /// assert_eq!(EventObjectLink::new("e", "o").qualifier(), None);
415 /// ```
416 #[must_use]
417 pub fn qualifier(&self) -> Option<&str> {
418 self.qualifier.as_deref()
419 }
420}
421
422/// An object-to-object (O2O) link: a typed relationship between two objects.
423///
424/// The optional `qualifier` names the relationship (e.g. `"contains"`,
425/// `"belongs_to"`). A link to an undeclared object is refused as
426/// [`OcelRefusal::DanglingObjectObjectLink`].
427///
428/// Structure-only: a typed edge between objects, not a mined dependency.
429#[derive(Clone, Debug, PartialEq, Eq)]
430pub struct ObjectObjectLink {
431 source_id: String,
432 target_id: String,
433 qualifier: Option<String>,
434}
435
436impl ObjectObjectLink {
437 /// Construct an unqualified O2O link from `source` to `target`.
438 ///
439 /// ```
440 /// use wasm4pm_compat::ocel::ObjectObjectLink;
441 /// let l = ObjectObjectLink::new("ord-1", "item-9");
442 /// assert_eq!(l.source_id(), "ord-1");
443 /// assert_eq!(l.target_id(), "item-9");
444 /// ```
445 pub fn new(source_id: impl Into<String>, target_id: impl Into<String>) -> Self {
446 ObjectObjectLink {
447 source_id: source_id.into(),
448 target_id: target_id.into(),
449 qualifier: None,
450 }
451 }
452
453 /// Attach a relationship qualifier. Builder-style.
454 ///
455 /// ```
456 /// use wasm4pm_compat::ocel::ObjectObjectLink;
457 /// let l = ObjectObjectLink::new("ord-1", "item-9").qualified("contains");
458 /// assert_eq!(l.qualifier(), Some("contains"));
459 /// ```
460 pub fn qualified(mut self, qualifier: impl Into<String>) -> Self {
461 self.qualifier = Some(qualifier.into());
462 self
463 }
464
465 /// The source object id.
466 ///
467 /// ```
468 /// use wasm4pm_compat::ocel::ObjectObjectLink;
469 /// assert_eq!(ObjectObjectLink::new("a", "b").source_id(), "a");
470 /// ```
471 pub fn source_id(&self) -> &str {
472 &self.source_id
473 }
474
475 /// The target object id.
476 ///
477 /// ```
478 /// use wasm4pm_compat::ocel::ObjectObjectLink;
479 /// assert_eq!(ObjectObjectLink::new("a", "b").target_id(), "b");
480 /// ```
481 pub fn target_id(&self) -> &str {
482 &self.target_id
483 }
484
485 /// The optional relationship qualifier.
486 ///
487 /// ```
488 /// use wasm4pm_compat::ocel::ObjectObjectLink;
489 /// assert_eq!(ObjectObjectLink::new("a", "b").qualifier(), None);
490 /// ```
491 #[must_use]
492 pub fn qualifier(&self) -> Option<&str> {
493 self.qualifier.as_deref()
494 }
495}
496
497/// A recorded change to an object attribute (the OCEL 2.0 object-evolution
498/// notion).
499///
500/// Objects are not static: an order's `status`, an item's `price`, may change
501/// over the process. An `ObjectChange` records *which* object's *which*
502/// attribute took *which* value, optionally *when*. A change naming no object
503/// or no attribute is refused as [`OcelRefusal::InvalidObjectChange`].
504///
505/// Structure-only: it records the change tuple; it does not replay object
506/// evolution.
507#[derive(Clone, Debug, PartialEq, Eq)]
508pub struct ObjectChange {
509 object_id: String,
510 attribute: String,
511 value: String,
512 timestamp_ns: Option<i64>,
513}
514
515impl ObjectChange {
516 /// Construct an object change: `object_id.attribute = value`.
517 ///
518 /// ```
519 /// use wasm4pm_compat::ocel::ObjectChange;
520 /// let c = ObjectChange::new("ord-1", "status", "paid");
521 /// assert_eq!(c.object_id(), "ord-1");
522 /// assert_eq!(c.attribute(), "status");
523 /// assert_eq!(c.value(), "paid");
524 /// ```
525 pub fn new(
526 object_id: impl Into<String>,
527 attribute: impl Into<String>,
528 value: impl Into<String>,
529 ) -> Self {
530 ObjectChange {
531 object_id: object_id.into(),
532 attribute: attribute.into(),
533 value: value.into(),
534 timestamp_ns: None,
535 }
536 }
537
538 /// Attach a nanosecond timestamp to the change. Builder-style.
539 ///
540 /// ```
541 /// use wasm4pm_compat::ocel::ObjectChange;
542 /// let c = ObjectChange::new("o", "a", "v").at_ns(7);
543 /// assert_eq!(c.timestamp_ns(), Some(7));
544 /// ```
545 pub fn at_ns(mut self, ts: i64) -> Self {
546 self.timestamp_ns = Some(ts);
547 self
548 }
549
550 /// The changed object's id.
551 ///
552 /// ```
553 /// use wasm4pm_compat::ocel::ObjectChange;
554 /// assert_eq!(ObjectChange::new("o", "a", "v").object_id(), "o");
555 /// ```
556 pub fn object_id(&self) -> &str {
557 &self.object_id
558 }
559
560 /// The changed attribute name.
561 ///
562 /// ```
563 /// use wasm4pm_compat::ocel::ObjectChange;
564 /// assert_eq!(ObjectChange::new("o", "a", "v").attribute(), "a");
565 /// ```
566 pub fn attribute(&self) -> &str {
567 &self.attribute
568 }
569
570 /// The new attribute value.
571 ///
572 /// ```
573 /// use wasm4pm_compat::ocel::ObjectChange;
574 /// assert_eq!(ObjectChange::new("o", "a", "v").value(), "v");
575 /// ```
576 pub fn value(&self) -> &str {
577 &self.value
578 }
579
580 /// The optional timestamp of the change.
581 ///
582 /// ```
583 /// use wasm4pm_compat::ocel::ObjectChange;
584 /// assert_eq!(ObjectChange::new("o", "a", "v").timestamp_ns(), None);
585 /// ```
586 #[must_use]
587 pub fn timestamp_ns(&self) -> Option<i64> {
588 self.timestamp_ns
589 }
590}
591
592/// A complete object-centric event log: objects, events, E2O & O2O links, and
593/// object changes.
594///
595/// This is the OCEL canon as one value. [`OcelLog::validate`] checks structural
596/// integrity (no dangling links, no duplicate ids, no empty types); it does not
597/// mine anything.
598///
599/// Structure-only: an admitted `OcelLog` is a substrate for object-centric
600/// discovery and conformance, which graduate to `wasm4pm`.
601#[doc(alias = "object-centric event log")]
602#[doc(alias = "OCEL")]
603#[derive(Clone, Debug, Default, PartialEq)]
604pub struct OcelLog {
605 objects: Vec<OcelObject>,
606 events: Vec<OcelEvent>,
607 e2o: Vec<EventObjectLink>,
608 o2o: Vec<ObjectObjectLink>,
609 changes: Vec<ObjectChange>,
610}
611
612impl OcelLog {
613 /// Construct an OCEL log from its five constituent tables.
614 ///
615 /// ```
616 /// use wasm4pm_compat::ocel::{OcelObject, OcelEvent, EventObjectLink, OcelLog};
617 /// let log = OcelLog::new(
618 /// [OcelObject::new("ord-1", "order")],
619 /// [OcelEvent::new("e1", "place_order")],
620 /// [EventObjectLink::new("e1", "ord-1")],
621 /// [],
622 /// [],
623 /// );
624 /// assert!(log.validate().is_ok());
625 /// ```
626 pub fn new(
627 objects: impl IntoIterator<Item = OcelObject>,
628 events: impl IntoIterator<Item = OcelEvent>,
629 e2o: impl IntoIterator<Item = EventObjectLink>,
630 o2o: impl IntoIterator<Item = ObjectObjectLink>,
631 changes: impl IntoIterator<Item = ObjectChange>,
632 ) -> Self {
633 OcelLog {
634 objects: objects.into_iter().collect(),
635 events: events.into_iter().collect(),
636 e2o: e2o.into_iter().collect(),
637 o2o: o2o.into_iter().collect(),
638 changes: changes.into_iter().collect(),
639 }
640 }
641
642 /// The declared objects.
643 pub fn objects(&self) -> &[OcelObject] {
644 &self.objects
645 }
646
647 /// The declared events.
648 pub fn events(&self) -> &[OcelEvent] {
649 &self.events
650 }
651
652 /// The event-to-object (E2O) links.
653 pub fn event_object_links(&self) -> &[EventObjectLink] {
654 &self.e2o
655 }
656
657 /// The object-to-object (O2O) links.
658 pub fn object_object_links(&self) -> &[ObjectObjectLink] {
659 &self.o2o
660 }
661
662 /// The recorded object changes.
663 pub fn object_changes(&self) -> &[ObjectChange] {
664 &self.changes
665 }
666
667 /// Structurally validate the OCEL log.
668 ///
669 /// This is a **structure check, not mining**. It verifies, in order:
670 /// - there is at least one object ([`OcelRefusal::MissingObject`]) and one
671 /// event ([`OcelRefusal::MissingEvent`]);
672 /// - object and event ids are each unique
673 /// ([`OcelRefusal::DuplicateObjectId`], [`OcelRefusal::DuplicateEventId`]);
674 /// - every object names a non-empty type ([`OcelRefusal::MissingObjectType`]);
675 /// - at least one E2O link exists ([`OcelRefusal::EmptyEventObjectLinks`]);
676 /// - every E2O link references a declared event and object
677 /// ([`OcelRefusal::DanglingEventObjectLink`]);
678 /// - every O2O link references declared objects
679 /// ([`OcelRefusal::DanglingObjectObjectLink`]);
680 /// - every object change references a declared object and names an attribute
681 /// ([`OcelRefusal::InvalidObjectChange`]).
682 ///
683 /// ```
684 /// use wasm4pm_compat::ocel::{OcelObject, OcelEvent, EventObjectLink, OcelLog, OcelRefusal};
685 /// // Dangling E2O link: references object "ghost" that was never declared.
686 /// let log = OcelLog::new(
687 /// [OcelObject::new("ord-1", "order")],
688 /// [OcelEvent::new("e1", "a")],
689 /// [EventObjectLink::new("e1", "ghost")],
690 /// [],
691 /// [],
692 /// );
693 /// assert_eq!(log.validate(), Err(OcelRefusal::DanglingEventObjectLink));
694 /// ```
695 #[must_use = "check the shape-check result"]
696 pub fn validate(&self) -> Result<(), OcelRefusal> {
697 if self.objects.is_empty() {
698 return Err(OcelRefusal::MissingObject);
699 }
700 if self.events.is_empty() {
701 return Err(OcelRefusal::MissingEvent);
702 }
703
704 let mut object_ids: HashSet<&str> = HashSet::new();
705 for o in &self.objects {
706 if o.object_type().is_empty() {
707 return Err(OcelRefusal::MissingObjectType);
708 }
709 if !object_ids.insert(o.id()) {
710 return Err(OcelRefusal::DuplicateObjectId);
711 }
712 }
713
714 let mut event_ids: HashSet<&str> = HashSet::new();
715 for e in &self.events {
716 if !event_ids.insert(e.id()) {
717 return Err(OcelRefusal::DuplicateEventId);
718 }
719 }
720
721 if self.e2o.is_empty() {
722 return Err(OcelRefusal::EmptyEventObjectLinks);
723 }
724 for l in &self.e2o {
725 if !event_ids.contains(l.event_id()) || !object_ids.contains(l.object_id()) {
726 return Err(OcelRefusal::DanglingEventObjectLink);
727 }
728 }
729
730 for l in &self.o2o {
731 if !object_ids.contains(l.source_id()) || !object_ids.contains(l.target_id()) {
732 return Err(OcelRefusal::DanglingObjectObjectLink);
733 }
734 }
735
736 for c in &self.changes {
737 if c.attribute().is_empty() || !object_ids.contains(c.object_id()) {
738 return Err(OcelRefusal::InvalidObjectChange);
739 }
740 }
741
742 Ok(())
743 }
744}
745
746// ── IntoIterator for OcelLog ─────────────────────────────────────────────────
747
748impl<'a> IntoIterator for &'a OcelLog {
749 type Item = &'a OcelEvent;
750 type IntoIter = core::slice::Iter<'a, OcelEvent>;
751
752 /// Iterate over the [`OcelEvent`]s of this log.
753 ///
754 /// The idiomatic OCEL iteration surface. Objects, E2O links, and O2O links
755 /// are accessible via [`OcelLog::objects`], [`OcelLog::event_object_links`],
756 /// and [`OcelLog::object_object_links`] respectively.
757 ///
758 /// ```
759 /// use wasm4pm_compat::ocel::{OcelLog, OcelObject, OcelEvent, EventObjectLink};
760 /// let log = OcelLog::new(
761 /// [OcelObject::new("o1", "order")],
762 /// [OcelEvent::new("e1", "place"), OcelEvent::new("e2", "ship")],
763 /// [EventObjectLink::new("e1", "o1"), EventObjectLink::new("e2", "o1")],
764 /// [],
765 /// [],
766 /// );
767 /// let activities: Vec<&str> = (&log).into_iter().map(|e| e.activity()).collect();
768 /// assert_eq!(activities, ["place", "ship"]);
769 /// ```
770 fn into_iter(self) -> Self::IntoIter {
771 self.events.iter()
772 }
773}
774
775impl IntoIterator for OcelLog {
776 type Item = OcelEvent;
777 type IntoIter = std::vec::IntoIter<OcelEvent>;
778
779 /// Consume the log and iterate over its [`OcelEvent`]s by value.
780 ///
781 /// ```
782 /// use wasm4pm_compat::ocel::{OcelLog, OcelObject, OcelEvent, EventObjectLink};
783 /// let log = OcelLog::new(
784 /// [OcelObject::new("o1", "order")],
785 /// [OcelEvent::new("e1", "place")],
786 /// [EventObjectLink::new("e1", "o1")],
787 /// [],
788 /// [],
789 /// );
790 /// let v: Vec<OcelEvent> = log.into_iter().collect();
791 /// assert_eq!(v.len(), 1);
792 /// ```
793 fn into_iter(self) -> Self::IntoIter {
794 self.events.into_iter()
795 }
796}
797
798/// The specific, named laws under which OCEL structure is refused.
799///
800/// Each variant cites a distinct structural law — never a bare "invalid input".
801/// [`OcelRefusal::FlatteningLoss`] in particular guards the OCEL→case-centric
802/// boundary: flattening is lossy and must go through a named projection with a
803/// loss policy and report, not a silent re-shape.
804#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
805#[non_exhaustive]
806pub enum OcelRefusal {
807 /// The log declares no objects.
808 MissingObject,
809 /// The log declares no events.
810 MissingEvent,
811 /// The log declares no event-to-object links (an OCEL with no E2O is empty).
812 EmptyEventObjectLinks,
813 /// An E2O link references an undeclared event or object.
814 DanglingEventObjectLink,
815 /// An O2O link references an undeclared object.
816 DanglingObjectObjectLink,
817 /// Two objects share the same id.
818 DuplicateObjectId,
819 /// Two events share the same id.
820 DuplicateEventId,
821 /// Flattening to a single case notion would lose convergence/divergence
822 /// information; requires a named projection with loss policy and report.
823 FlatteningLoss,
824 /// An object names an empty type.
825 MissingObjectType,
826 /// An object change references an undeclared object or names no attribute.
827 InvalidObjectChange,
828}
829
830// ── OcelAttributeValue: Display + From conversions ───────────────────────────
831
832impl core::fmt::Display for OcelAttributeValue {
833 /// Human-readable representation of an OCEL attribute value.
834 ///
835 /// - `String(s)` → the string content directly (no quotes)
836 /// - `Integer(n)` → decimal integer
837 /// - `Float(f)` → decimal float
838 /// - `Boolean(b)` → `"true"` or `"false"`
839 /// - `TimestampNs(n)` → `"@<n>ns"` (nanoseconds since Unix epoch)
840 /// - `List([…])` → `"[v1, v2, …]"`
841 /// - `Map({…})` → `"{k1: v1, k2: v2, …}"`
842 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
843 match self {
844 OcelAttributeValue::String(s) => f.write_str(s),
845 OcelAttributeValue::Integer(n) => write!(f, "{n}"),
846 OcelAttributeValue::Float(v) => write!(f, "{v}"),
847 OcelAttributeValue::Boolean(b) => write!(f, "{b}"),
848 OcelAttributeValue::TimestampNs(n) => write!(f, "@{n}ns"),
849 OcelAttributeValue::List(items) => {
850 f.write_str("[")?;
851 for (i, item) in items.iter().enumerate() {
852 if i > 0 {
853 f.write_str(", ")?;
854 }
855 write!(f, "{item}")?;
856 }
857 f.write_str("]")
858 }
859 OcelAttributeValue::Map(pairs) => {
860 f.write_str("{")?;
861 for (i, (k, v)) in pairs.iter().enumerate() {
862 if i > 0 {
863 f.write_str(", ")?;
864 }
865 write!(f, "{k}: {v}")?;
866 }
867 f.write_str("}")
868 }
869 }
870 }
871}
872
873impl From<String> for OcelAttributeValue {
874 /// Infallible conversion: `String` → `OcelAttributeValue::String`.
875 fn from(s: String) -> Self {
876 OcelAttributeValue::String(s)
877 }
878}
879
880impl From<&str> for OcelAttributeValue {
881 /// Infallible conversion: `&str` → `OcelAttributeValue::String` (allocates, same as `String::from`).
882 fn from(s: &str) -> Self {
883 OcelAttributeValue::String(s.to_owned())
884 }
885}
886
887impl From<i64> for OcelAttributeValue {
888 /// Infallible conversion: `i64` → `OcelAttributeValue::Integer`.
889 fn from(n: i64) -> Self {
890 OcelAttributeValue::Integer(n)
891 }
892}
893
894impl From<f64> for OcelAttributeValue {
895 /// Infallible conversion: `f64` → `OcelAttributeValue::Float`.
896 fn from(v: f64) -> Self {
897 OcelAttributeValue::Float(v)
898 }
899}
900
901impl From<bool> for OcelAttributeValue {
902 /// Infallible conversion: `bool` → `OcelAttributeValue::Boolean`.
903 fn from(b: bool) -> Self {
904 OcelAttributeValue::Boolean(b)
905 }
906}
907
908impl core::fmt::Display for OcelRefusal {
909 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
910 let law = match self {
911 OcelRefusal::MissingObject => "MissingObject",
912 OcelRefusal::MissingEvent => "MissingEvent",
913 OcelRefusal::EmptyEventObjectLinks => "EmptyEventObjectLinks",
914 OcelRefusal::DanglingEventObjectLink => "DanglingEventObjectLink",
915 OcelRefusal::DanglingObjectObjectLink => "DanglingObjectObjectLink",
916 OcelRefusal::DuplicateObjectId => "DuplicateObjectId",
917 OcelRefusal::DuplicateEventId => "DuplicateEventId",
918 OcelRefusal::FlatteningLoss => "FlatteningLoss",
919 OcelRefusal::MissingObjectType => "MissingObjectType",
920 OcelRefusal::InvalidObjectChange => "InvalidObjectChange",
921 };
922 write!(f, "OCEL refused by law: {law}")
923 }
924}
925
926/// Dimension summary for an OCEL log: the observed object types and activity
927/// names.
928///
929/// `OcelDims` captures the *vocabulary* of an [`OcelLog`] — all object types and
930/// activity names that appear — without materialising the log's relational
931/// tables. It is useful as a fast header check before full admission.
932///
933/// Structure-only: it names dimensions; it does not count, mine, or index them.
934/// Counting activities per object type, building object-type × activity
935/// incidence tables, and computing object-type DFGs graduate to `wasm4pm`.
936#[derive(Clone, Debug, Default, PartialEq, Eq)]
937pub struct OcelDims {
938 /// Distinct object types observed in this log.
939 pub object_types: Vec<String>,
940 /// Distinct activity names observed in this log.
941 pub activities: Vec<String>,
942}
943
944impl OcelDims {
945 /// Derive the dimension vocabulary from an [`OcelLog`].
946 ///
947 /// ```
948 /// use wasm4pm_compat::ocel::{OcelDims, OcelLog, OcelObject, OcelEvent, EventObjectLink};
949 /// let log = OcelLog::new(
950 /// [OcelObject::new("o1", "order"), OcelObject::new("i1", "item")],
951 /// [OcelEvent::new("e1", "place")],
952 /// [EventObjectLink::new("e1", "o1")],
953 /// [],
954 /// [],
955 /// );
956 /// let dims = OcelDims::from_log(&log);
957 /// assert!(dims.object_types.contains(&"order".to_string()));
958 /// assert!(dims.activities.contains(&"place".to_string()));
959 /// ```
960 #[must_use]
961 pub fn from_log(log: &OcelLog) -> Self {
962 use std::collections::BTreeSet;
963 let object_types: BTreeSet<String> = log
964 .objects()
965 .iter()
966 .map(|o| o.object_type().to_owned())
967 .collect();
968 let activities: BTreeSet<String> = log
969 .events()
970 .iter()
971 .map(|e| e.activity().to_owned())
972 .collect();
973 OcelDims {
974 object_types: object_types.into_iter().collect(),
975 activities: activities.into_iter().collect(),
976 }
977 }
978
979 /// Whether this log has any declared object types.
980 ///
981 /// ```
982 /// use wasm4pm_compat::ocel::OcelDims;
983 /// assert!(OcelDims::default().is_empty());
984 /// ```
985 #[must_use]
986 pub fn is_empty(&self) -> bool {
987 self.object_types.is_empty() && self.activities.is_empty()
988 }
989}
990
991// ── Object-type witness ───────────────────────────────────────────────────────
992
993/// A phantom type-level object-type tag.
994///
995/// `ObjectTypeTag` is a zero-sized marker that threads a *named object type*
996/// through generic typed surfaces. It prevents `TypedObject<OrderTag>` from
997/// being silently coerced into `TypedObject<ItemTag>` at the type level.
998///
999/// Structure-only: it is a compile-time label. Object-type classification,
1000/// filtering, and DFG construction graduate to `wasm4pm`.
1001///
1002/// # Graduation
1003///
1004/// When an object-type distinction must be *enforced at runtime* (e.g. checking
1005/// that all events touching a given type appear only in a certain activity set),
1006/// the typed surface travels to `wasm4pm`.
1007pub trait ObjectTypeTag: core::fmt::Debug + Clone + PartialEq {
1008 /// The stable, lowercase, machine-facing object-type name (e.g. `"order"`).
1009 const TYPE_NAME: &'static str;
1010}
1011
1012/// A typed wrapper around [`OcelObject`] that threads an [`ObjectTypeTag`] into
1013/// the type system.
1014///
1015/// `TypedObject<OT>` is a newtype around [`OcelObject`] whose phantom type
1016/// parameter `OT: ObjectTypeTag` makes the object's type visible at compile
1017/// time. It is not possible to mix `TypedObject<OrderTag>` and
1018/// `TypedObject<ItemTag>` without an explicit coercion through the inner
1019/// [`OcelObject`].
1020///
1021/// Structure-only: it carries identity, type tag, and attributes; it does not
1022/// compute object behavior or validate against a schema. That graduates to
1023/// `wasm4pm`.
1024#[derive(Clone, Debug, PartialEq)]
1025pub struct TypedObject<OT: ObjectTypeTag> {
1026 inner: OcelObject,
1027 _tag: core::marker::PhantomData<OT>,
1028}
1029
1030impl<OT: ObjectTypeTag> TypedObject<OT> {
1031 /// Wrap an [`OcelObject`] with a compile-time object-type tag.
1032 ///
1033 /// The `object_type` field on the inner [`OcelObject`] is **not** replaced;
1034 /// callers should ensure it matches `OT::TYPE_NAME` if that consistency is
1035 /// required. The type-level tag is a compile-time claim, not a runtime check.
1036 ///
1037 /// ```
1038 /// use wasm4pm_compat::ocel::{OcelObject, TypedObject, ObjectTypeTag};
1039 ///
1040 /// #[derive(Clone, Debug, PartialEq)]
1041 /// struct OrderTag;
1042 /// impl ObjectTypeTag for OrderTag { const TYPE_NAME: &'static str = "order"; }
1043 ///
1044 /// let obj = OcelObject::new("ord-1", "order");
1045 /// let typed = TypedObject::<OrderTag>::wrap(obj);
1046 /// assert_eq!(typed.inner().id(), "ord-1");
1047 /// ```
1048 pub fn wrap(inner: OcelObject) -> Self {
1049 TypedObject {
1050 inner,
1051 _tag: core::marker::PhantomData,
1052 }
1053 }
1054
1055 /// Construct a [`TypedObject`] directly: id plus the tag's type name.
1056 ///
1057 /// ```
1058 /// use wasm4pm_compat::ocel::{TypedObject, ObjectTypeTag};
1059 ///
1060 /// #[derive(Clone, Debug, PartialEq)]
1061 /// struct ItemTag;
1062 /// impl ObjectTypeTag for ItemTag { const TYPE_NAME: &'static str = "item"; }
1063 ///
1064 /// let obj = TypedObject::<ItemTag>::new("item-7");
1065 /// assert_eq!(obj.inner().object_type(), "item");
1066 /// ```
1067 pub fn new(id: impl Into<String>) -> Self {
1068 TypedObject {
1069 inner: OcelObject::new(id, OT::TYPE_NAME),
1070 _tag: core::marker::PhantomData,
1071 }
1072 }
1073
1074 /// The inner untyped [`OcelObject`].
1075 ///
1076 /// ```
1077 /// use wasm4pm_compat::ocel::{OcelObject, TypedObject, ObjectTypeTag};
1078 ///
1079 /// #[derive(Clone, Debug, PartialEq)]
1080 /// struct OrderTag;
1081 /// impl ObjectTypeTag for OrderTag { const TYPE_NAME: &'static str = "order"; }
1082 ///
1083 /// let typed = TypedObject::<OrderTag>::new("ord-3");
1084 /// assert_eq!(typed.inner().object_type(), "order");
1085 /// ```
1086 pub fn inner(&self) -> &OcelObject {
1087 &self.inner
1088 }
1089
1090 /// Consume the wrapper and return the inner [`OcelObject`].
1091 ///
1092 /// ```
1093 /// use wasm4pm_compat::ocel::{TypedObject, ObjectTypeTag};
1094 ///
1095 /// #[derive(Clone, Debug, PartialEq)]
1096 /// struct OrderTag;
1097 /// impl ObjectTypeTag for OrderTag { const TYPE_NAME: &'static str = "order"; }
1098 ///
1099 /// let obj = TypedObject::<OrderTag>::new("ord-5").into_inner();
1100 /// assert_eq!(obj.id(), "ord-5");
1101 /// ```
1102 pub fn into_inner(self) -> OcelObject {
1103 self.inner
1104 }
1105}
1106
1107// ── Event-type witness ────────────────────────────────────────────────────────
1108
1109/// A phantom type-level event-type tag.
1110///
1111/// `EventTypeTag` is a zero-sized marker that threads a *named activity type*
1112/// through generic typed surfaces. It prevents `TypedEvent<PlaceOrderTag>` from
1113/// being silently coerced into `TypedEvent<ShipTag>` at the type level.
1114///
1115/// Structure-only: it is a compile-time label. Activity-type classification
1116/// and typed process-tree construction graduate to `wasm4pm`.
1117pub trait EventTypeTag: core::fmt::Debug + Clone + PartialEq {
1118 /// The stable, lowercase, machine-facing activity-type name (e.g. `"place_order"`).
1119 const ACTIVITY_NAME: &'static str;
1120}
1121
1122/// A typed wrapper around [`OcelEvent`] that threads an [`EventTypeTag`] into
1123/// the type system.
1124///
1125/// `TypedEvent<ET>` is a newtype around [`OcelEvent`] whose phantom type
1126/// parameter `ET: EventTypeTag` makes the event's activity visible at compile
1127/// time.
1128///
1129/// Structure-only: it carries identity, activity tag, timestamp, and
1130/// attributes; it does not replay, mine, or conform. That graduates to `wasm4pm`.
1131#[derive(Clone, Debug, PartialEq)]
1132pub struct TypedEvent<ET: EventTypeTag> {
1133 inner: OcelEvent,
1134 _tag: core::marker::PhantomData<ET>,
1135}
1136
1137impl<ET: EventTypeTag> TypedEvent<ET> {
1138 /// Wrap an [`OcelEvent`] with a compile-time activity-type tag.
1139 ///
1140 /// ```
1141 /// use wasm4pm_compat::ocel::{OcelEvent, TypedEvent, EventTypeTag};
1142 ///
1143 /// #[derive(Clone, Debug, PartialEq)]
1144 /// struct PlaceOrderTag;
1145 /// impl EventTypeTag for PlaceOrderTag { const ACTIVITY_NAME: &'static str = "place_order"; }
1146 ///
1147 /// let ev = OcelEvent::new("e1", "place_order");
1148 /// let typed = TypedEvent::<PlaceOrderTag>::wrap(ev);
1149 /// assert_eq!(typed.inner().activity(), "place_order");
1150 /// ```
1151 pub fn wrap(inner: OcelEvent) -> Self {
1152 TypedEvent {
1153 inner,
1154 _tag: core::marker::PhantomData,
1155 }
1156 }
1157
1158 /// Construct a [`TypedEvent`] directly: id plus the tag's activity name.
1159 ///
1160 /// ```
1161 /// use wasm4pm_compat::ocel::{TypedEvent, EventTypeTag};
1162 ///
1163 /// #[derive(Clone, Debug, PartialEq)]
1164 /// struct ShipTag;
1165 /// impl EventTypeTag for ShipTag { const ACTIVITY_NAME: &'static str = "ship"; }
1166 ///
1167 /// let ev = TypedEvent::<ShipTag>::new("e2");
1168 /// assert_eq!(ev.inner().activity(), "ship");
1169 /// ```
1170 pub fn new(id: impl Into<String>) -> Self {
1171 TypedEvent {
1172 inner: OcelEvent::new(id, ET::ACTIVITY_NAME),
1173 _tag: core::marker::PhantomData,
1174 }
1175 }
1176
1177 /// The inner untyped [`OcelEvent`].
1178 ///
1179 /// ```
1180 /// use wasm4pm_compat::ocel::{TypedEvent, EventTypeTag};
1181 ///
1182 /// #[derive(Clone, Debug, PartialEq)]
1183 /// struct ShipTag;
1184 /// impl EventTypeTag for ShipTag { const ACTIVITY_NAME: &'static str = "ship"; }
1185 ///
1186 /// let ev = TypedEvent::<ShipTag>::new("e3");
1187 /// assert_eq!(ev.inner().id(), "e3");
1188 /// ```
1189 pub fn inner(&self) -> &OcelEvent {
1190 &self.inner
1191 }
1192
1193 /// Consume the wrapper and return the inner [`OcelEvent`].
1194 ///
1195 /// ```
1196 /// use wasm4pm_compat::ocel::{TypedEvent, EventTypeTag};
1197 ///
1198 /// #[derive(Clone, Debug, PartialEq)]
1199 /// struct ShipTag;
1200 /// impl EventTypeTag for ShipTag { const ACTIVITY_NAME: &'static str = "ship"; }
1201 ///
1202 /// let ev = TypedEvent::<ShipTag>::new("e4").into_inner();
1203 /// assert_eq!(ev.activity(), "ship");
1204 /// ```
1205 pub fn into_inner(self) -> OcelEvent {
1206 self.inner
1207 }
1208}
1209
1210// ── Attribute-type witness ────────────────────────────────────────────────────
1211
1212/// A phantom type-level attribute-type tag.
1213///
1214/// `AttributeTypeTag` is a zero-sized marker that threads a *named attribute
1215/// domain* (e.g. "status", "price", "quantity") through attribute-typed generic
1216/// surfaces. It prevents `TypedAttribute<StatusTag>` from being silently
1217/// coerced into `TypedAttribute<PriceTag>` at the type level.
1218///
1219/// Structure-only: it is a compile-time label. Attribute-domain validation,
1220/// aggregation, and normalization graduate to `wasm4pm`.
1221pub trait AttributeTypeTag: core::fmt::Debug + Clone + PartialEq {
1222 /// The stable, lowercase, machine-facing attribute name (e.g. `"status"`).
1223 const ATTR_NAME: &'static str;
1224}
1225
1226/// A typed wrapper around [`OcelAttribute`] that threads an [`AttributeTypeTag`]
1227/// into the type system.
1228///
1229/// `TypedAttribute<AT>` is a newtype around [`OcelAttribute`] whose phantom
1230/// type parameter `AT: AttributeTypeTag` makes the attribute's domain visible
1231/// at compile time.
1232///
1233/// Structure-only: it wraps a key/value pair with a compile-time domain tag; it
1234/// does not interpret or validate the value. That graduates to `wasm4pm`.
1235#[derive(Clone, Debug, PartialEq)]
1236pub struct TypedAttribute<AT: AttributeTypeTag> {
1237 inner: OcelAttribute,
1238 _tag: core::marker::PhantomData<AT>,
1239}
1240
1241impl<AT: AttributeTypeTag> TypedAttribute<AT> {
1242 /// Wrap an [`OcelAttribute`] with a compile-time attribute-type tag.
1243 ///
1244 /// ```
1245 /// use wasm4pm_compat::ocel::{OcelAttribute, TypedAttribute, AttributeTypeTag};
1246 ///
1247 /// #[derive(Clone, Debug, PartialEq)]
1248 /// struct StatusTag;
1249 /// impl AttributeTypeTag for StatusTag { const ATTR_NAME: &'static str = "status"; }
1250 ///
1251 /// let attr = OcelAttribute::string("status", "open");
1252 /// let typed = TypedAttribute::<StatusTag>::wrap(attr);
1253 /// assert_eq!(typed.inner().key, "status");
1254 /// ```
1255 pub fn wrap(inner: OcelAttribute) -> Self {
1256 TypedAttribute {
1257 inner,
1258 _tag: core::marker::PhantomData,
1259 }
1260 }
1261
1262 /// The inner untyped [`OcelAttribute`].
1263 ///
1264 /// ```
1265 /// use wasm4pm_compat::ocel::{OcelAttribute, TypedAttribute, AttributeTypeTag};
1266 ///
1267 /// #[derive(Clone, Debug, PartialEq)]
1268 /// struct PriceTag;
1269 /// impl AttributeTypeTag for PriceTag { const ATTR_NAME: &'static str = "price"; }
1270 ///
1271 /// let typed = TypedAttribute::<PriceTag>::wrap(OcelAttribute::float("price", 9.99));
1272 /// assert_eq!(typed.inner().key, "price");
1273 /// ```
1274 pub fn inner(&self) -> &OcelAttribute {
1275 &self.inner
1276 }
1277
1278 /// Consume the wrapper and return the inner [`OcelAttribute`].
1279 ///
1280 /// ```
1281 /// use wasm4pm_compat::ocel::{OcelAttribute, TypedAttribute, AttributeTypeTag};
1282 ///
1283 /// #[derive(Clone, Debug, PartialEq)]
1284 /// struct PriceTag;
1285 /// impl AttributeTypeTag for PriceTag { const ATTR_NAME: &'static str = "price"; }
1286 ///
1287 /// let attr = TypedAttribute::<PriceTag>::wrap(OcelAttribute::float("price", 1.5)).into_inner();
1288 /// assert_eq!(attr.key, "price");
1289 /// ```
1290 pub fn into_inner(self) -> OcelAttribute {
1291 self.inner
1292 }
1293}
1294
1295// ── TypedObjectChange: object-change construction with typed value ────────────
1296
1297/// A recorded change to an object attribute, with a typed [`OcelAttributeValue`].
1298///
1299/// `TypedObjectChange` is the typed companion to [`ObjectChange`]: instead of
1300/// storing the new value as a `String`, it stores an [`OcelAttributeValue`] so
1301/// the type of the change (integer, float, boolean, …) is visible without
1302/// parsing. A change naming no object or no attribute should be refused as
1303/// [`OcelRefusal::InvalidObjectChange`].
1304///
1305/// Structure-only: it records the typed change tuple; it does not replay object
1306/// evolution or compute a delta. That graduates to `wasm4pm`.
1307#[derive(Clone, Debug, PartialEq)]
1308pub struct TypedObjectChange {
1309 object_id: String,
1310 attribute: String,
1311 value: OcelAttributeValue,
1312 timestamp_ns: Option<i64>,
1313}
1314
1315impl TypedObjectChange {
1316 /// Construct a typed object change: `object_id.attribute = value`.
1317 ///
1318 /// ```
1319 /// use wasm4pm_compat::ocel::{TypedObjectChange, OcelAttributeValue};
1320 /// let c = TypedObjectChange::new("ord-1", "price", OcelAttributeValue::Float(49.99));
1321 /// assert_eq!(c.object_id(), "ord-1");
1322 /// assert_eq!(c.attribute(), "price");
1323 /// assert_eq!(c.value(), &OcelAttributeValue::Float(49.99));
1324 /// ```
1325 pub fn new(
1326 object_id: impl Into<String>,
1327 attribute: impl Into<String>,
1328 value: OcelAttributeValue,
1329 ) -> Self {
1330 TypedObjectChange {
1331 object_id: object_id.into(),
1332 attribute: attribute.into(),
1333 value,
1334 timestamp_ns: None,
1335 }
1336 }
1337
1338 /// Attach a nanosecond timestamp to the change. Builder-style.
1339 ///
1340 /// ```
1341 /// use wasm4pm_compat::ocel::{TypedObjectChange, OcelAttributeValue};
1342 /// let c = TypedObjectChange::new("o", "a", OcelAttributeValue::Boolean(true)).at_ns(42);
1343 /// assert_eq!(c.timestamp_ns(), Some(42));
1344 /// ```
1345 pub fn at_ns(mut self, ts: i64) -> Self {
1346 self.timestamp_ns = Some(ts);
1347 self
1348 }
1349
1350 /// The changed object's id.
1351 ///
1352 /// ```
1353 /// use wasm4pm_compat::ocel::{TypedObjectChange, OcelAttributeValue};
1354 /// assert_eq!(TypedObjectChange::new("o", "a", OcelAttributeValue::Integer(1)).object_id(), "o");
1355 /// ```
1356 pub fn object_id(&self) -> &str {
1357 &self.object_id
1358 }
1359
1360 /// The changed attribute name.
1361 ///
1362 /// ```
1363 /// use wasm4pm_compat::ocel::{TypedObjectChange, OcelAttributeValue};
1364 /// assert_eq!(TypedObjectChange::new("o", "a", OcelAttributeValue::Integer(1)).attribute(), "a");
1365 /// ```
1366 pub fn attribute(&self) -> &str {
1367 &self.attribute
1368 }
1369
1370 /// The new typed attribute value.
1371 ///
1372 /// ```
1373 /// use wasm4pm_compat::ocel::{TypedObjectChange, OcelAttributeValue};
1374 /// let c = TypedObjectChange::new("o", "a", OcelAttributeValue::Integer(7));
1375 /// assert_eq!(c.value(), &OcelAttributeValue::Integer(7));
1376 /// ```
1377 pub fn value(&self) -> &OcelAttributeValue {
1378 &self.value
1379 }
1380
1381 /// The optional timestamp of the change.
1382 ///
1383 /// ```
1384 /// use wasm4pm_compat::ocel::{TypedObjectChange, OcelAttributeValue};
1385 /// assert_eq!(TypedObjectChange::new("o", "a", OcelAttributeValue::Boolean(false)).timestamp_ns(), None);
1386 /// ```
1387 #[must_use]
1388 pub fn timestamp_ns(&self) -> Option<i64> {
1389 self.timestamp_ns
1390 }
1391}