-
Notifications
You must be signed in to change notification settings - Fork 0
feat(event): refuse subevents that escape the parent interval #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d168761
97c6363
b8f4582
8e15967
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| [package] | ||
| name = "subevent_containment" | ||
| description = "Subevent intervals must stay inside the parent event interval." | ||
| version.workspace = true | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| authors.workspace = true | ||
| repository.workspace = true | ||
| homepage.workspace = true | ||
| readme.workspace = true | ||
| keywords.workspace = true | ||
| categories.workspace = true | ||
| publish = false | ||
|
|
||
| [lints] | ||
| workspace = true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| //! Fail-closed subevent-containment errors. | ||
|
|
||
| use std::fmt; | ||
|
|
||
| /// A fail-closed subevent-containment error. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum SubeventContainmentError { | ||
| /// The subevent interval is not inside the parent interval. | ||
| SubeventEscapesParent, | ||
| /// An interval or recovery slice was empty, inverted, or length-mismatched. | ||
| InvalidIntervalPayload, | ||
| } | ||
|
|
||
| impl fmt::Display for SubeventContainmentError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| let message = match self { | ||
| Self::SubeventEscapesParent => "subevent interval escapes the parent event", | ||
| Self::InvalidIntervalPayload => "invalid subevent-containment payload", | ||
| }; | ||
| formatter.write_str(message) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for SubeventContainmentError {} | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::SubeventContainmentError; | ||
|
|
||
| #[test] | ||
| fn error_messages_are_stable() { | ||
| for (error, message) in [ | ||
| ( | ||
| SubeventContainmentError::SubeventEscapesParent, | ||
| "subevent interval escapes the parent event", | ||
| ), | ||
| ( | ||
| SubeventContainmentError::InvalidIntervalPayload, | ||
| "invalid subevent-containment payload", | ||
| ), | ||
| ] { | ||
| assert_eq!(error.to_string(), message); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| //! Half-open event-time intervals and parent containment. | ||
|
|
||
| use crate::SubeventContainmentError; | ||
|
|
||
| /// One half-open event-time interval `[start, end)` in seconds. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| pub struct EventInterval { | ||
| start_seconds: i64, | ||
| end_seconds: i64, | ||
| } | ||
|
|
||
| impl EventInterval { | ||
| /// Construct a half-open interval with a strictly positive length. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`SubeventContainmentError::InvalidIntervalPayload`] when | ||
| /// `end_seconds` is not greater than `start_seconds`. | ||
| pub const fn new( | ||
| start_seconds: i64, | ||
| end_seconds: i64, | ||
| ) -> Result<Self, SubeventContainmentError> { | ||
| if end_seconds <= start_seconds { | ||
| return Err(SubeventContainmentError::InvalidIntervalPayload); | ||
| } | ||
| Ok(Self { | ||
| start_seconds, | ||
| end_seconds, | ||
| }) | ||
| } | ||
|
|
||
| /// Inclusive start bound in seconds. | ||
| #[must_use] | ||
| pub const fn start_seconds(self) -> i64 { | ||
| self.start_seconds | ||
| } | ||
|
|
||
| /// Exclusive end bound in seconds. | ||
| #[must_use] | ||
| pub const fn end_seconds(self) -> i64 { | ||
| self.end_seconds | ||
| } | ||
| } | ||
|
|
||
| /// Return whether `child` lies entirely inside `parent`. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// This function is infallible for validated intervals and exists to keep the | ||
| /// public comparison surface explicit. | ||
| #[allow(clippy::unnecessary_wraps)] | ||
| pub fn interval_contains( | ||
| parent: EventInterval, | ||
| child: EventInterval, | ||
| ) -> Result<bool, SubeventContainmentError> { | ||
| Ok(child.start_seconds >= parent.start_seconds && child.end_seconds <= parent.end_seconds) | ||
| } | ||
|
Comment on lines
+52
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Containment logic is correct for half-open intervals
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| /// Refuse to attach a subevent that escapes the parent interval. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`SubeventContainmentError::SubeventEscapesParent`] when the child | ||
| /// is not contained. | ||
| pub fn refuse_escaped_subevent( | ||
| parent: EventInterval, | ||
| child: EventInterval, | ||
| ) -> Result<(), SubeventContainmentError> { | ||
| if interval_contains(parent, child)? { | ||
| return Ok(()); | ||
| } | ||
| Err(SubeventContainmentError::SubeventEscapesParent) | ||
| } | ||
|
|
||
| /// Fraction of recovered containment flags that match known truth. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`SubeventContainmentError::InvalidIntervalPayload`] when either | ||
| /// slice is empty or the lengths differ. | ||
| pub fn containment_recovery_rate( | ||
| truth: &[bool], | ||
| decided: &[bool], | ||
| ) -> Result<f64, SubeventContainmentError> { | ||
| if truth.is_empty() || truth.len() != decided.len() { | ||
| return Err(SubeventContainmentError::InvalidIntervalPayload); | ||
| } | ||
| let mut matches = 0_u32; | ||
| for (truth_flag, decided_flag) in truth.iter().zip(decided) { | ||
| if truth_flag == decided_flag { | ||
| matches += 1; | ||
| } | ||
| } | ||
| Ok(f64::from(matches) / truth.len() as f64) | ||
| } | ||
|
Comment on lines
+81
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Recovery-rate empty/length checks match documented contract
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ | ||
| EventInterval, containment_recovery_rate, interval_contains, refuse_escaped_subevent, | ||
| }; | ||
| use crate::SubeventContainmentError; | ||
|
|
||
| #[test] | ||
| fn local_branches_cover_containment_and_payloads() { | ||
| let parent = EventInterval::new(10, 40).expect("parent"); | ||
| let inside = EventInterval::new(15, 30).expect("inside"); | ||
| assert_eq!(parent.start_seconds(), 10); | ||
| assert_eq!(parent.end_seconds(), 40); | ||
| assert!(interval_contains(parent, inside).expect("inside")); | ||
| refuse_escaped_subevent(parent, inside).expect("contained"); | ||
| let early = EventInterval::new(0, 20).expect("early"); | ||
| assert!(!interval_contains(parent, early).expect("early")); | ||
| assert_eq!( | ||
| refuse_escaped_subevent(parent, early), | ||
| Err(SubeventContainmentError::SubeventEscapesParent) | ||
| ); | ||
| assert_eq!( | ||
| EventInterval::new(4, 4), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| let matched = containment_recovery_rate(&[true], &[true]).expect("rate"); | ||
| assert!((matched - 1.0).abs() < f64::EPSILON); | ||
| assert_eq!( | ||
| containment_recovery_rate(&[], &[]), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| assert_eq!( | ||
| containment_recovery_rate(&[true], &[]), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| #![forbid(unsafe_code)] | ||
| #![deny(missing_docs)] | ||
| #![allow(clippy::cast_precision_loss)] | ||
| //! Subevent intervals must stay inside the parent event interval. | ||
| //! | ||
| //! A subevent is part of a versioned event instance. Its event-time interval | ||
| //! cannot start before or end after the parent (ADR 0003). | ||
|
|
||
| mod error; | ||
| mod interval; | ||
|
|
||
| /// Fail-closed subevent-containment errors. | ||
| pub use error::SubeventContainmentError; | ||
| /// One half-open event-time interval. | ||
| pub use interval::EventInterval; | ||
| /// Fraction of recovered containment flags that match known truth. | ||
| pub use interval::containment_recovery_rate; | ||
| /// Return whether a child interval lies entirely inside a parent interval. | ||
| pub use interval::interval_contains; | ||
| /// Refuse to attach a subevent that escapes the parent interval. | ||
| pub use interval::refuse_escaped_subevent; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| //! A subevent cannot escape its parent event-time interval. | ||
|
|
||
| use subevent_containment::{ | ||
| EventInterval, SubeventContainmentError, containment_recovery_rate, interval_contains, | ||
| refuse_escaped_subevent, | ||
| }; | ||
|
|
||
| fn interval(start: i64, end: i64) -> EventInterval { | ||
| EventInterval::new(start, end).expect("interval") | ||
| } | ||
|
|
||
| #[test] | ||
| fn escaped_subevents_cannot_attach_to_the_parent() { | ||
| let parent = interval(10, 40); | ||
| let inside = interval(15, 30); | ||
| let early = interval(0, 20); | ||
| let late = interval(30, 50); | ||
| assert!(interval_contains(parent, inside).expect("inside")); | ||
| refuse_escaped_subevent(parent, inside).expect("contained"); | ||
| assert!(!interval_contains(parent, early).expect("early")); | ||
| assert_eq!( | ||
| refuse_escaped_subevent(parent, early), | ||
| Err(SubeventContainmentError::SubeventEscapesParent) | ||
| ); | ||
| assert_eq!( | ||
| refuse_escaped_subevent(parent, late), | ||
| Err(SubeventContainmentError::SubeventEscapesParent) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn recovered_containment_matches_known_truth_better_than_accepting_all() { | ||
| let parent = interval(10, 40); | ||
| let children = [interval(15, 30), interval(0, 20), interval(12, 18)]; | ||
| let truth = [true, false, true]; | ||
| let recovered = [ | ||
| interval_contains(parent, children[0]).expect("c0"), | ||
| interval_contains(parent, children[1]).expect("c1"), | ||
| interval_contains(parent, children[2]).expect("c2"), | ||
| ]; | ||
| let collapsed = [true, true, true]; | ||
| let recovered_rate = containment_recovery_rate(&truth, &recovered).expect("recovered"); | ||
| let collapsed_rate = containment_recovery_rate(&truth, &collapsed).expect("collapsed"); | ||
| let expected = { | ||
| let mut matches = 0_u32; | ||
| for (truth_flag, decided_flag) in truth.iter().zip(recovered.iter()) { | ||
| if truth_flag == decided_flag { | ||
| matches += 1; | ||
| } | ||
| } | ||
| f64::from(matches) / f64::from(u32::try_from(truth.len()).expect("len")) | ||
| }; | ||
| assert!((recovered_rate - expected).abs() < f64::EPSILON); | ||
| assert!(recovered_rate > collapsed_rate); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_or_invalid_interval_payloads_fail_closed() { | ||
| assert_eq!( | ||
| EventInterval::new(10, 10), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| assert_eq!( | ||
| EventInterval::new(10, 9), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| assert_eq!( | ||
| containment_recovery_rate(&[], &[]), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| assert_eq!( | ||
| containment_recovery_rate(&[true], &[]), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| assert_eq!( | ||
| containment_recovery_rate(&[true, false], &[true]), | ||
| Err(SubeventContainmentError::InvalidIntervalPayload) | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| //! Integration contract for the `subevent_containment` package identity. | ||
|
|
||
| #[test] | ||
| fn package_identity_is_stable() { | ||
| let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); | ||
| assert_eq!(observed, "subevent_containment"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: interval_contains documented as infallible yet returns Result
interval_containsat interval.rs always returnsOk(..)and its# Errorsdocstring states it is infallible; theResultwrapper and#[allow(clippy::unnecessary_wraps)]exist only to keep the public surface explicit.refuse_escaped_subeventpropagates the never-taken?. This is intentional per the docs but worth noting: consumers must still handle an error arm that can never occur, which slightly complicates the API contract.Was this helpful? React with 👍 or 👎 to provide feedback.