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}