Skip to main content

wasm4pm_compat/
loss.rs

1//! Loss policy, loss report, named-projection law, and named-loss descriptor.
2//!
3//! Some translations between process-evidence shapes **cannot** be lossless.
4//! The canonical case is flattening an object-centric log (OCEL) down to a
5//! classic single-case log (XES): you must pick *one* object type to act as the
6//! case notion, and every event-to-object link to the other types is discarded.
7//! That discarded structure is real evidence — it cannot vanish silently.
8//!
9//! This module makes loss **accountable**:
10//!
11//! - [`crate::loss::Project`] is the only sanctioned lossy transformation. It is named, and
12//!   it is gated by a [`crate::loss::LossPolicy`].
13//! - [`crate::loss::LossPolicy`] forces a caller to *decide in advance* how loss is handled:
14//!   refuse it, allow it under a named projection, or allow it but emit a
15//!   [`crate::loss::LossReport`]. Use [`crate::loss::LossPolicy::is_refusing`], [`crate::loss::LossPolicy::is_named`],
16//!   and [`crate::loss::LossPolicy::is_reporting`] to guard on intent without pattern-matching.
17//! - [`crate::loss::LossReport`] is the receipt of what was lost — it records the
18//!   [`crate::loss::ProjectionName`], the policy, and the discarded items. Use
19//!   [`crate::loss::LossReport::summary`] to derive a [`crate::loss::NamedLoss`] and
20//!   [`crate::loss::LossReport::is_lossless`] (where `Items: `[`crate::loss::IsEmpty`]) to detect
21//!   vacuously lossless projections.
22//! - [`crate::loss::ProjectionName`] is a `&'static str` newtype implementing [`Display`][core::fmt::Display],
23//!   making projection identifiers embeddable in diagnostic output.
24//! - [`crate::loss::NamedLoss`] pairs a [`crate::loss::ProjectionName`] with a loss-category label so a
25//!   specific loss occurrence is auditable by both projection identity and kind.
26//!
27//! No raw format-to-format laundering is permitted: lossy projection requires a
28//! named projection + a [`crate::loss::LossPolicy`] + a [`crate::loss::LossReport`] + a refusal path. See
29//! [`crate::diagnostic::CompatDiagnostic::LossyProjectionWithoutPolicy`] and
30//! [`crate::diagnostic::CompatDiagnostic::HiddenFlattening`].
31//!
32//! Structure only: this module *accounts for* loss; it does not *perform*
33//! discovery on the projected result. Graduate to `wasm4pm` to act on it.
34
35use core::marker::PhantomData;
36
37/// How a lossy projection must be handled — decided **before** loss occurs.
38///
39/// A projection that drops evidence must be governed by exactly one of these
40/// policies. Choosing [`LossPolicy::RefuseLoss`] turns any would-be loss into a
41/// refusal; the other two require the loss to be named and (for
42/// [`LossPolicy::AllowLossWithReport`]) itemized in a [`LossReport`].
43///
44/// Structure-only label. It states the *rule of engagement* for loss; it does
45/// not itself compute what is lost.
46#[doc(alias = "projection policy")]
47#[doc(alias = "loss")]
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum LossPolicy {
50    /// Loss is not tolerated: a projection that would drop evidence must refuse.
51    RefuseLoss,
52    /// Loss is permitted, but only via an explicitly *named* projection
53    /// ([`ProjectionName`]). Items need not be enumerated.
54    AllowNamedProjection,
55    /// Loss is permitted and must be *reported*: a [`LossReport`] enumerating the
56    /// discarded items is produced alongside the result.
57    AllowLossWithReport,
58}
59
60impl Default for LossPolicy {
61    /// The safest default: refuse all loss.
62    ///
63    /// Callers that do not explicitly select a policy receive
64    /// [`LossPolicy::RefuseLoss`], preventing silent structure loss. Use a
65    /// builder or explicit enum variant when loss is intentional.
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use wasm4pm_compat::loss::LossPolicy;
71    /// assert_eq!(LossPolicy::default(), LossPolicy::RefuseLoss);
72    /// assert!(LossPolicy::default().is_refusing());
73    /// ```
74    #[inline]
75    fn default() -> Self {
76        LossPolicy::RefuseLoss
77    }
78}
79
80impl LossPolicy {
81    /// Returns `true` when this policy requires refusing any loss.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use wasm4pm_compat::loss::LossPolicy;
87    ///
88    /// assert!(LossPolicy::RefuseLoss.is_refusing());
89    /// assert!(!LossPolicy::AllowNamedProjection.is_refusing());
90    /// assert!(!LossPolicy::AllowLossWithReport.is_refusing());
91    /// ```
92    #[inline]
93    pub const fn is_refusing(self) -> bool {
94        matches!(self, LossPolicy::RefuseLoss)
95    }
96
97    /// Returns `true` when this policy permits loss under a named projection
98    /// (items need not be enumerated).
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use wasm4pm_compat::loss::LossPolicy;
104    ///
105    /// assert!(!LossPolicy::RefuseLoss.is_named());
106    /// assert!(LossPolicy::AllowNamedProjection.is_named());
107    /// assert!(!LossPolicy::AllowLossWithReport.is_named());
108    /// ```
109    #[inline]
110    pub const fn is_named(self) -> bool {
111        matches!(self, LossPolicy::AllowNamedProjection)
112    }
113
114    /// Returns `true` when this policy permits loss and requires a full
115    /// itemized [`LossReport`].
116    ///
117    /// # Examples
118    ///
119    /// ```
120    /// use wasm4pm_compat::loss::LossPolicy;
121    ///
122    /// assert!(!LossPolicy::RefuseLoss.is_reporting());
123    /// assert!(!LossPolicy::AllowNamedProjection.is_reporting());
124    /// assert!(LossPolicy::AllowLossWithReport.is_reporting());
125    /// ```
126    #[inline]
127    pub const fn is_reporting(self) -> bool {
128        matches!(self, LossPolicy::AllowLossWithReport)
129    }
130}
131
132impl core::fmt::Display for LossPolicy {
133    /// Formats as the variant name for diagnostics and log output.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use wasm4pm_compat::loss::LossPolicy;
139    ///
140    /// assert_eq!(format!("{}", LossPolicy::RefuseLoss),          "RefuseLoss");
141    /// assert_eq!(format!("{}", LossPolicy::AllowNamedProjection), "AllowNamedProjection");
142    /// assert_eq!(format!("{}", LossPolicy::AllowLossWithReport),  "AllowLossWithReport");
143    /// ```
144    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
145        match self {
146            LossPolicy::RefuseLoss => f.write_str("RefuseLoss"),
147            LossPolicy::AllowNamedProjection => f.write_str("AllowNamedProjection"),
148            LossPolicy::AllowLossWithReport => f.write_str("AllowLossWithReport"),
149        }
150    }
151}
152
153/// The stable name of a projection (e.g. `"ocel-flatten-to-xes:by-order"`).
154///
155/// A [`ProjectionName`] makes a lossy transformation *recognizable* and
156/// *auditable*: two runs of the same named projection mean the same thing.
157/// It is a thin `&'static str` newtype so names live in the binary, are cheap to
158/// pass, and cannot be confused with arbitrary user strings.
159///
160/// Structure-only identifier. It names the projection; it does not implement it.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162pub struct ProjectionName(pub &'static str);
163
164impl ProjectionName {
165    /// Borrows the underlying static name.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use wasm4pm_compat::loss::ProjectionName;
171    ///
172    /// let name = ProjectionName("ocel-flatten-to-xes:by-order");
173    /// assert_eq!(name.as_str(), "ocel-flatten-to-xes:by-order");
174    /// ```
175    #[inline]
176    pub const fn as_str(self) -> &'static str {
177        self.0
178    }
179
180    /// Consumes `self` and returns the underlying `&'static str`.
181    ///
182    /// Identical to [`as_str`](Self::as_str) (since `&'static str` is `Copy`);
183    /// provided for newtype-wrapper ergonomics.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use wasm4pm_compat::loss::ProjectionName;
189    /// let n = ProjectionName("p");
190    /// assert_eq!(n.into_inner(), "p");
191    /// ```
192    #[inline]
193    pub const fn into_inner(self) -> &'static str {
194        self.0
195    }
196
197    /// Borrows the underlying `&'static str`.
198    ///
199    /// Identical to [`as_str`](Self::as_str); provided for newtype-wrapper
200    /// ergonomics so callers can use `as_inner()` uniformly.
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// use wasm4pm_compat::loss::ProjectionName;
206    /// let n = ProjectionName("p");
207    /// assert_eq!(n.as_inner(), "p");
208    /// ```
209    #[inline]
210    pub const fn as_inner(&self) -> &'static str {
211        self.0
212    }
213}
214
215impl From<&'static str> for ProjectionName {
216    /// Constructs a [`ProjectionName`] from a static string literal.
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use wasm4pm_compat::loss::ProjectionName;
222    ///
223    /// let name: ProjectionName = "ocel-flatten-to-xes:by-order".into();
224    /// assert_eq!(name.as_str(), "ocel-flatten-to-xes:by-order");
225    /// ```
226    #[inline]
227    fn from(s: &'static str) -> Self {
228        ProjectionName(s)
229    }
230}
231
232impl AsRef<str> for ProjectionName {
233    /// Borrows the underlying static string.
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use wasm4pm_compat::loss::ProjectionName;
239    ///
240    /// let name = ProjectionName("ocel-flatten-to-xes:by-order");
241    /// assert_eq!(name.as_ref(), "ocel-flatten-to-xes:by-order");
242    /// ```
243    #[inline]
244    fn as_ref(&self) -> &str {
245        self.0
246    }
247}
248
249impl core::fmt::Display for ProjectionName {
250    /// Formats the projection name for diagnostics and log output.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use wasm4pm_compat::loss::ProjectionName;
256    ///
257    /// let name = ProjectionName("ocel-flatten-to-xes:by-order");
258    /// assert_eq!(format!("{}", name), "ocel-flatten-to-xes:by-order");
259    /// ```
260    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
261        f.write_str(self.0)
262    }
263}
264
265/// A named descriptor for a specific category of loss under a projection.
266///
267/// A [`NamedLoss`] pairs a [`ProjectionName`] with a `&'static str` label that
268/// names the *kind* of loss that occurred (e.g. `"DroppedObjectTypeLinks"` or
269/// `"FlattenedMultiObjectRelation"`).  Together they make a specific loss
270/// occurrence *auditable by name*: both *which projection* ran and *which law*
271/// it violated are explicit on the type, not buried in a `String`.
272///
273/// Use [`NamedLoss`] as the `Lost` type parameter of a [`LossReport`] when the
274/// most important fact is the *category* of loss rather than a full item list.
275///
276/// Structure-only: carries no engine logic. Graduate to `wasm4pm` to act on it.
277///
278/// # Examples
279///
280/// ```
281/// use wasm4pm_compat::loss::{LossPolicy, LossReport, NamedLoss, ProjectionName};
282///
283/// enum OcelShape {}
284/// enum XesShape {}
285///
286/// let loss = NamedLoss::new(
287///     ProjectionName("ocel-flatten-to-xes:by-order"),
288///     "DroppedObjectTypeLinks",
289/// );
290/// assert_eq!(loss.projection().as_str(), "ocel-flatten-to-xes:by-order");
291/// assert_eq!(loss.category(), "DroppedObjectTypeLinks");
292/// ```
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
294pub struct NamedLoss {
295    projection: ProjectionName,
296    category: &'static str,
297}
298
299impl NamedLoss {
300    /// Constructs a [`NamedLoss`] from a projection name and a loss category label.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use wasm4pm_compat::loss::{NamedLoss, ProjectionName};
306    ///
307    /// let loss = NamedLoss::new(
308    ///     ProjectionName("ocel-flatten-to-xes:by-order"),
309    ///     "DroppedObjectTypeLinks",
310    /// );
311    /// assert_eq!(loss.category(), "DroppedObjectTypeLinks");
312    /// ```
313    #[inline]
314    pub const fn new(projection: ProjectionName, category: &'static str) -> Self {
315        NamedLoss {
316            projection,
317            category,
318        }
319    }
320
321    /// Returns the [`ProjectionName`] under which this loss occurred.
322    ///
323    /// # Examples
324    ///
325    /// ```
326    /// use wasm4pm_compat::loss::{NamedLoss, ProjectionName};
327    ///
328    /// let loss = NamedLoss::new(ProjectionName("p"), "SomeLoss");
329    /// assert_eq!(loss.projection().as_str(), "p");
330    /// ```
331    #[inline]
332    pub const fn projection(self) -> ProjectionName {
333        self.projection
334    }
335
336    /// Returns the named loss category label.
337    ///
338    /// # Examples
339    ///
340    /// ```
341    /// use wasm4pm_compat::loss::{NamedLoss, ProjectionName};
342    ///
343    /// let loss = NamedLoss::new(ProjectionName("p"), "FlattenedMultiObjectRelation");
344    /// assert_eq!(loss.category(), "FlattenedMultiObjectRelation");
345    /// ```
346    #[inline]
347    pub const fn category(self) -> &'static str {
348        self.category
349    }
350}
351
352impl core::fmt::Display for NamedLoss {
353    /// Formats as `<projection>/<category>` for diagnostic and log output.
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use wasm4pm_compat::loss::{NamedLoss, ProjectionName};
359    ///
360    /// let loss = NamedLoss::new(
361    ///     ProjectionName("ocel-flatten-to-xes:by-order"),
362    ///     "DroppedObjectTypeLinks",
363    /// );
364    /// assert_eq!(
365    ///     format!("{}", loss),
366    ///     "ocel-flatten-to-xes:by-order/DroppedObjectTypeLinks",
367    /// );
368    /// ```
369    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
370        write!(f, "{}/{}", self.projection, self.category)
371    }
372}
373
374/// The receipt of a lossy projection: what projection ran, under what policy,
375/// and exactly which items were discarded.
376///
377/// The `From` and `To` type parameters tag the shapes the projection bridged
378/// (zero-sized `PhantomData`), so a report cannot be mistaken for one between
379/// different shapes. `Items` is the concrete record of discarded evidence (e.g.
380/// a `Vec` of dropped object types).
381///
382/// Structure-only: a `LossReport` proves loss was *accounted for*; it is not a
383/// repair tool. Carry it alongside the projected value so the loss travels on
384/// the record.
385pub struct LossReport<From, To, Items> {
386    /// The named projection that produced this report.
387    pub projection: ProjectionName,
388    /// The policy under which the projection was authorized.
389    pub policy: LossPolicy,
390    /// The concrete evidence items that were discarded.
391    pub lost: Items,
392    from: PhantomData<From>,
393    to: PhantomData<To>,
394}
395
396// Manual `Clone`/`Debug` so the `From`/`To` shape markers need not themselves
397// be `Clone`/`Debug` (they are zero-sized `PhantomData` tags).
398impl<From, To, Items: Clone> Clone for LossReport<From, To, Items> {
399    #[inline]
400    fn clone(&self) -> Self {
401        LossReport {
402            projection: self.projection,
403            policy: self.policy,
404            lost: self.lost.clone(),
405            from: PhantomData,
406            to: PhantomData,
407        }
408    }
409}
410
411impl<From, To, Items: core::fmt::Debug> core::fmt::Debug for LossReport<From, To, Items> {
412    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
413        f.debug_struct("LossReport")
414            .field("projection", &self.projection)
415            .field("policy", &self.policy)
416            .field("lost", &self.lost)
417            .finish()
418    }
419}
420
421impl<From, To, Items> LossReport<From, To, Items> {
422    /// Builds a loss report for a named projection under a given policy.
423    ///
424    /// # Examples
425    ///
426    /// ```
427    /// use wasm4pm_compat::loss::{LossPolicy, LossReport, ProjectionName};
428    ///
429    /// // OCEL → XES flattening drops links to the non-case object types.
430    /// enum Ocel {}
431    /// enum Xes {}
432    /// let report = LossReport::<Ocel, Xes, Vec<&str>>::new(
433    ///     ProjectionName("ocel-flatten-to-xes:by-order"),
434    ///     LossPolicy::AllowLossWithReport,
435    ///     vec!["item", "invoice"],
436    /// );
437    /// assert_eq!(report.policy, LossPolicy::AllowLossWithReport);
438    /// assert_eq!(report.lost, vec!["item", "invoice"]);
439    /// ```
440    #[inline]
441    pub const fn new(projection: ProjectionName, policy: LossPolicy, lost: Items) -> Self {
442        LossReport {
443            projection,
444            policy,
445            lost,
446            from: PhantomData,
447            to: PhantomData,
448        }
449    }
450
451    /// Consumes the report, yielding the discarded items.
452    ///
453    /// # Examples
454    ///
455    /// ```
456    /// use wasm4pm_compat::loss::{LossPolicy, LossReport, ProjectionName};
457    ///
458    /// enum A {}
459    /// enum B {}
460    /// let report = LossReport::<A, B, Vec<u32>>::new(
461    ///     ProjectionName("p"),
462    ///     LossPolicy::AllowLossWithReport,
463    ///     vec![1, 2, 3],
464    /// );
465    /// assert_eq!(report.into_lost(), vec![1, 2, 3]);
466    /// ```
467    #[inline]
468    pub fn into_lost(self) -> Items {
469        self.lost
470    }
471
472    /// Returns a [`NamedLoss`] summarizing this report as a named loss occurrence.
473    ///
474    /// The [`NamedLoss`] pairs the projection name with a caller-supplied category
475    /// label, making the specific category of loss auditable independently of the
476    /// full item list.
477    ///
478    /// # Examples
479    ///
480    /// ```
481    /// use wasm4pm_compat::loss::{LossPolicy, LossReport, NamedLoss, ProjectionName};
482    ///
483    /// enum OcelShape {}
484    /// enum XesShape {}
485    ///
486    /// let report = LossReport::<OcelShape, XesShape, Vec<&str>>::new(
487    ///     ProjectionName("ocel-flatten-to-xes:by-order"),
488    ///     LossPolicy::AllowLossWithReport,
489    ///     vec!["item", "invoice"],
490    /// );
491    /// let summary = report.summary("DroppedObjectTypeLinks");
492    /// assert_eq!(summary.projection().as_str(), "ocel-flatten-to-xes:by-order");
493    /// assert_eq!(summary.category(), "DroppedObjectTypeLinks");
494    /// ```
495    #[inline]
496    pub fn summary(&self, category: &'static str) -> NamedLoss {
497        NamedLoss::new(self.projection, category)
498    }
499}
500
501impl<From, To, Items: IsEmpty> LossReport<From, To, Items> {
502    /// Returns `true` when the report contains no discarded items.
503    ///
504    /// Only available when `Items` implements [`IsEmpty`] (blanket-impl on
505    /// `Vec<T>`, `&[T]`, and `&str`). A lossless report is valid even under
506    /// [`LossPolicy::RefuseLoss`] because no evidence was actually dropped.
507    ///
508    /// # Examples
509    ///
510    /// ```
511    /// use wasm4pm_compat::loss::{LossPolicy, LossReport, ProjectionName};
512    ///
513    /// enum A {}
514    /// enum B {}
515    ///
516    /// let empty = LossReport::<A, B, Vec<u8>>::new(
517    ///     ProjectionName("p"),
518    ///     LossPolicy::AllowLossWithReport,
519    ///     vec![],
520    /// );
521    /// assert!(empty.is_lossless());
522    ///
523    /// let non_empty = LossReport::<A, B, Vec<u8>>::new(
524    ///     ProjectionName("p"),
525    ///     LossPolicy::AllowLossWithReport,
526    ///     vec![1_u8],
527    /// );
528    /// assert!(!non_empty.is_lossless());
529    /// ```
530    #[inline]
531    pub fn is_lossless(&self) -> bool {
532        self.lost.is_empty()
533    }
534}
535
536/// Helper bound: types that can report whether they hold zero items.
537///
538/// Blanket-implemented for `Vec<T>`, `&[T]`, and `&str`. Not intended for
539/// downstream implementation; use it as a bound on [`LossReport::is_lossless`].
540///
541/// Structure-only helper trait. It carries no engine logic.
542pub trait IsEmpty {
543    /// Returns `true` when `self` holds no items.
544    fn is_empty(&self) -> bool;
545}
546
547impl<T> IsEmpty for Vec<T> {
548    #[inline]
549    fn is_empty(&self) -> bool {
550        Vec::is_empty(self)
551    }
552}
553
554impl<T> IsEmpty for &[T] {
555    #[inline]
556    fn is_empty(&self) -> bool {
557        <[T]>::is_empty(self)
558    }
559}
560
561impl IsEmpty for &str {
562    #[inline]
563    fn is_empty(&self) -> bool {
564        str::is_empty(self)
565    }
566}
567
568/// A **compile-time** named-loss marker: the loss category is baked in as a
569/// const generic `&'static str` so two distinct categories produce distinct
570/// types at zero runtime cost.
571///
572/// Use [`NamedLossConst`] when the loss category is known at compile time and
573/// you want the type system to enforce that a `DroppedObjectTypeLinks` report
574/// cannot be confused with a `FlattenedMultiObjectRelation` report.  For
575/// runtime-determined categories use [`NamedLoss`] instead.
576///
577/// Structure-only zero-sized marker.  It carries no engine logic; graduate to
578/// `wasm4pm` to act on it.
579///
580/// # Examples
581///
582/// ```
583/// use wasm4pm_compat::loss::NamedLossConst;
584///
585/// type DroppedLinks = NamedLossConst<"DroppedObjectTypeLinks">;
586/// type FlattenedRel = NamedLossConst<"FlattenedMultiObjectRelation">;
587///
588/// // The category name is recoverable at run time.
589/// assert_eq!(DroppedLinks::NAME, "DroppedObjectTypeLinks");
590/// assert_eq!(FlattenedRel::NAME, "FlattenedMultiObjectRelation");
591/// ```
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
593pub struct NamedLossConst<const NAME: &'static str>;
594
595impl<const NAME: &'static str> NamedLossConst<NAME> {
596    /// The loss-category label as a `&'static str`, recoverable at run time.
597    ///
598    /// # Examples
599    ///
600    /// ```
601    /// use wasm4pm_compat::loss::NamedLossConst;
602    ///
603    /// assert_eq!(
604    ///     NamedLossConst::<"DroppedObjectTypeLinks">::NAME,
605    ///     "DroppedObjectTypeLinks",
606    /// );
607    /// ```
608    pub const NAME: &'static str = NAME;
609}
610
611impl<const NAME: &'static str> core::fmt::Display for NamedLossConst<NAME> {
612    /// Formats as the loss category name.
613    ///
614    /// # Examples
615    ///
616    /// ```
617    /// use wasm4pm_compat::loss::NamedLossConst;
618    ///
619    /// assert_eq!(
620    ///     format!("{}", NamedLossConst::<"DroppedObjectTypeLinks">),
621    ///     "DroppedObjectTypeLinks",
622    /// );
623    /// ```
624    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
625        f.write_str(NAME)
626    }
627}
628
629/// A sequential chain of [`NamedLoss`] descriptors documenting a multi-step
630/// lossy pipeline.
631///
632/// When evidence passes through more than one lossy projection in sequence —
633/// e.g. OCEL → flattened XES → aggregated DFG — each step produces a
634/// [`NamedLoss`].  A [`LossChain`] collects every step in order so the full
635/// provenance trail is auditable as a single value.
636///
637/// Structure-only container.  It records the chain; it does not replay or
638/// reverse it.  Graduate to `wasm4pm` to reason over the accumulated loss.
639///
640/// # Examples
641///
642/// ```
643/// use wasm4pm_compat::loss::{LossChain, NamedLoss, ProjectionName};
644///
645/// let mut chain = LossChain::new();
646/// chain.push(NamedLoss::new(
647///     ProjectionName("ocel-flatten-to-xes:by-order"),
648///     "DroppedObjectTypeLinks",
649/// ));
650/// chain.push(NamedLoss::new(
651///     ProjectionName("xes-to-dfg:aggregate"),
652///     "FlattenedTimestamps",
653/// ));
654/// assert_eq!(chain.len(), 2);
655/// assert!(!chain.is_lossless());
656/// ```
657pub struct LossChain {
658    steps: Vec<NamedLoss>,
659}
660
661impl LossChain {
662    /// Creates an empty loss chain (no steps recorded yet).
663    ///
664    /// # Examples
665    ///
666    /// ```
667    /// use wasm4pm_compat::loss::LossChain;
668    ///
669    /// let chain = LossChain::new();
670    /// assert!(chain.is_lossless());
671    /// ```
672    #[inline]
673    pub fn new() -> Self {
674        LossChain { steps: Vec::new() }
675    }
676
677    /// Records a single [`NamedLoss`] step at the end of the chain.
678    ///
679    /// # Examples
680    ///
681    /// ```
682    /// use wasm4pm_compat::loss::{LossChain, NamedLoss, ProjectionName};
683    ///
684    /// let mut chain = LossChain::new();
685    /// chain.push(NamedLoss::new(ProjectionName("p"), "SomeLoss"));
686    /// assert_eq!(chain.len(), 1);
687    /// ```
688    #[inline]
689    pub fn push(&mut self, step: NamedLoss) {
690        self.steps.push(step);
691    }
692
693    /// Returns the number of loss steps recorded in this chain.
694    ///
695    /// # Examples
696    ///
697    /// ```
698    /// use wasm4pm_compat::loss::{LossChain, NamedLoss, ProjectionName};
699    ///
700    /// let mut chain = LossChain::new();
701    /// assert_eq!(chain.len(), 0);
702    /// chain.push(NamedLoss::new(ProjectionName("p"), "L"));
703    /// assert_eq!(chain.len(), 1);
704    /// ```
705    #[inline]
706    pub fn len(&self) -> usize {
707        self.steps.len()
708    }
709
710    /// Returns `true` when no loss steps have been recorded.
711    ///
712    /// A chain with zero steps represents a vacuously lossless pipeline.
713    /// Alias for [`LossChain::is_lossless`]; satisfies the `len`/`is_empty`
714    /// convention required by Clippy.
715    ///
716    /// # Examples
717    ///
718    /// ```
719    /// use wasm4pm_compat::loss::LossChain;
720    ///
721    /// assert!(LossChain::new().is_empty());
722    /// ```
723    #[inline]
724    pub fn is_empty(&self) -> bool {
725        self.steps.is_empty()
726    }
727
728    /// Returns `true` when no loss steps have been recorded.
729    ///
730    /// Semantic alias for [`LossChain::is_empty`] — use this name when the
731    /// intent is to communicate *no evidence was lost*, not just that the
732    /// container holds no elements.
733    ///
734    /// # Examples
735    ///
736    /// ```
737    /// use wasm4pm_compat::loss::LossChain;
738    ///
739    /// assert!(LossChain::new().is_lossless());
740    /// ```
741    #[inline]
742    pub fn is_lossless(&self) -> bool {
743        self.steps.is_empty()
744    }
745
746    /// Returns a slice over the recorded loss steps in order.
747    ///
748    /// # Examples
749    ///
750    /// ```
751    /// use wasm4pm_compat::loss::{LossChain, NamedLoss, ProjectionName};
752    ///
753    /// let mut chain = LossChain::new();
754    /// chain.push(NamedLoss::new(ProjectionName("p"), "A"));
755    /// chain.push(NamedLoss::new(ProjectionName("q"), "B"));
756    /// assert_eq!(chain.steps()[0].category(), "A");
757    /// assert_eq!(chain.steps()[1].category(), "B");
758    /// ```
759    #[inline]
760    pub fn steps(&self) -> &[NamedLoss] {
761        &self.steps
762    }
763
764    /// Appends every step from `other` onto `self`, consuming `other`.
765    ///
766    /// Useful for merging two sub-pipeline loss chains into the top-level chain.
767    ///
768    /// # Examples
769    ///
770    /// ```
771    /// use wasm4pm_compat::loss::{LossChain, NamedLoss, ProjectionName};
772    ///
773    /// let mut a = LossChain::new();
774    /// a.push(NamedLoss::new(ProjectionName("p"), "A"));
775    ///
776    /// let mut b = LossChain::new();
777    /// b.push(NamedLoss::new(ProjectionName("q"), "B"));
778    ///
779    /// a.extend(b);
780    /// assert_eq!(a.len(), 2);
781    /// ```
782    #[inline]
783    pub fn extend(&mut self, other: LossChain) {
784        self.steps.extend(other.steps);
785    }
786}
787
788impl Default for LossChain {
789    /// Returns an empty [`LossChain`].
790    ///
791    /// # Examples
792    ///
793    /// ```
794    /// use wasm4pm_compat::loss::LossChain;
795    ///
796    /// let chain: LossChain = Default::default();
797    /// assert!(chain.is_lossless());
798    /// ```
799    #[inline]
800    fn default() -> Self {
801        LossChain::new()
802    }
803}
804
805impl core::fmt::Debug for LossChain {
806    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
807        f.debug_struct("LossChain")
808            .field("steps", &self.steps)
809            .finish()
810    }
811}
812
813/// A zero-sized marker that names the **boundary** between two projection
814/// steps in a multi-step lossy pipeline.
815///
816/// In a pipeline such as `OCEL → flattened XES → aggregated DFG` there are
817/// two distinct boundaries where evidence may be dropped.  A
818/// [`ProjectionBoundary`] names each such crossing point so that a
819/// [`LossChain`] entry, a [`LossReport`], or a diagnostic can cite *which
820/// boundary* is accountable for a given loss — not just which overall
821/// pipeline.
822///
823/// The boundary is identified by a const `&'static str` NAME baked into the
824/// type so that two distinct boundaries produce distinct types at zero runtime
825/// cost.  For runtime-determined boundary names embed a [`ProjectionName`]
826/// in a [`NamedLoss`] instead.
827///
828/// Structure-only zero-sized marker.  It carries no engine logic; graduate
829/// to `wasm4pm` to reason over boundary crossings.
830///
831/// # Examples
832///
833/// ```
834/// use wasm4pm_compat::loss::ProjectionBoundary;
835///
836/// type OcelToXesBoundary    = ProjectionBoundary<"ocel→xes">;
837/// type XesToDfgBoundary     = ProjectionBoundary<"xes→dfg">;
838///
839/// assert_eq!(OcelToXesBoundary::NAME, "ocel→xes");
840/// assert_eq!(XesToDfgBoundary::NAME,  "xes→dfg");
841/// assert_ne!(OcelToXesBoundary::NAME, XesToDfgBoundary::NAME);
842/// ```
843#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
844pub struct ProjectionBoundary<const NAME: &'static str>;
845
846impl<const NAME: &'static str> ProjectionBoundary<NAME> {
847    /// The boundary label as a `&'static str`, recoverable at run time.
848    ///
849    /// # Examples
850    ///
851    /// ```
852    /// use wasm4pm_compat::loss::ProjectionBoundary;
853    ///
854    /// assert_eq!(
855    ///     ProjectionBoundary::<"ocel→xes">::NAME,
856    ///     "ocel→xes",
857    /// );
858    /// ```
859    pub const NAME: &'static str = NAME;
860
861    /// Returns a [`ProjectionName`] for this boundary so it can be embedded
862    /// in a [`LossReport`] or [`NamedLoss`] without an extra allocation.
863    ///
864    /// # Examples
865    ///
866    /// ```
867    /// use wasm4pm_compat::loss::{ProjectionBoundary, ProjectionName};
868    ///
869    /// let pn: ProjectionName = ProjectionBoundary::<"ocel→xes">::projection_name();
870    /// assert_eq!(pn.as_str(), "ocel→xes");
871    /// ```
872    #[inline]
873    pub const fn projection_name() -> ProjectionName {
874        ProjectionName(NAME)
875    }
876}
877
878impl<const NAME: &'static str> core::fmt::Display for ProjectionBoundary<NAME> {
879    /// Formats as the boundary label.
880    ///
881    /// # Examples
882    ///
883    /// ```
884    /// use wasm4pm_compat::loss::ProjectionBoundary;
885    ///
886    /// assert_eq!(
887    ///     format!("{}", ProjectionBoundary::<"ocel→xes">),
888    ///     "ocel→xes",
889    /// );
890    /// ```
891    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
892        f.write_str(NAME)
893    }
894}
895
896/// The named lossy-projection law — the only sanctioned way to drop evidence.
897///
898/// An implementor names a single projection (`Self::From → Self::To`) that may
899/// discard `Self::Lost`. It must honor the supplied [`LossPolicy`]: under
900/// [`LossPolicy::RefuseLoss`] it returns `Self::Reason` instead of losing
901/// anything; otherwise it returns a [`LossReport`] recording the loss.
902///
903/// Structure-only contract. `project` accounts for loss by shape; it does not
904/// run an engine over the result. Graduate to `wasm4pm` to act on the projected
905/// shape.
906///
907/// # Examples
908///
909/// ```
910/// use wasm4pm_compat::loss::{LossPolicy, LossReport, Project, ProjectionName};
911///
912/// /// Flatten an OCEL (modeled here as a list of object types) to a single
913/// /// case object type, dropping the rest.
914/// struct OcelFlatten {
915///     object_types: Vec<&'static str>,
916///     case_type: &'static str,
917/// }
918///
919/// enum OcelShape {}
920/// enum XesShape {}
921///
922/// impl Project for OcelFlatten {
923///     type From = OcelShape;
924///     type To = XesShape;
925///     type Lost = Vec<&'static str>;
926///     type Reason = &'static str;
927///     fn project(
928///         self,
929///         policy: LossPolicy,
930///     ) -> Result<LossReport<Self::From, Self::To, Self::Lost>, Self::Reason> {
931///         let dropped: Vec<&'static str> =
932///             self.object_types.iter().copied().filter(|t| *t != self.case_type).collect();
933///         if !dropped.is_empty() && policy == LossPolicy::RefuseLoss {
934///             return Err("FlatteningLoss");
935///         }
936///         Ok(LossReport::new(
937///             ProjectionName("ocel-flatten-to-xes:by-case"),
938///             policy,
939///             dropped,
940///         ))
941///     }
942/// }
943///
944/// let flat = OcelFlatten { object_types: vec!["order", "item"], case_type: "order" };
945/// // RefuseLoss path: dropping "item" is refused with a *named* reason.
946/// let refused = OcelFlatten { object_types: vec!["order", "item"], case_type: "order" }
947///     .project(LossPolicy::RefuseLoss);
948/// assert_eq!(refused.err(), Some("FlatteningLoss"));
949/// // Reporting path: the loss is allowed and recorded.
950/// let report = flat.project(LossPolicy::AllowLossWithReport).unwrap();
951/// assert_eq!(report.lost, vec!["item"]);
952/// ```
953pub trait Project {
954    /// The shape being projected from.
955    type From;
956    /// The shape being projected to.
957    type To;
958    /// The concrete record of discarded evidence.
959    type Lost;
960    /// The *named* refusal reason when loss is not permitted.
961    type Reason;
962
963    /// Projects under `policy`, either reporting the loss or refusing it.
964    ///
965    /// The return type intentionally spells out
966    /// `Result<LossReport<…>, Reason>` rather than hiding it behind an alias:
967    /// the *shape of the verdict* (report-the-loss or named-refuse) is the
968    /// contract, imported verbatim by other surfaces.
969    #[allow(clippy::type_complexity)]
970    fn project(
971        self,
972        policy: LossPolicy,
973    ) -> Result<LossReport<Self::From, Self::To, Self::Lost>, Self::Reason>;
974}