Skip to main content

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}