-
Notifications
You must be signed in to change notification settings - Fork 0
feat(temporal): refuse event and system time as availability #124
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
7f5d37b
77089cd
e89b90c
94939aa
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 = "available_clock" | ||
| description = "Availability time cannot be replaced by event or system time." | ||
| 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,99 @@ | ||
| //! Clock-family identity for availability stamps. | ||
|
|
||
| use crate::AvailableClockError; | ||
|
|
||
| /// Closed vocabulary of clocks that must not be confused with availability. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| pub enum ClockFamily { | ||
| /// Event/valid time. | ||
| EventTime, | ||
| /// System/record time. | ||
| SystemTime, | ||
| /// Availability time. | ||
| AvailableTime, | ||
| } | ||
|
|
||
| /// Return whether a stamp is on the availability clock. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// This function is infallible for the closed vocabulary and exists to keep | ||
| /// the public comparison surface explicit. | ||
| #[allow(clippy::unnecessary_wraps)] | ||
| pub fn stamp_is_available(family: ClockFamily) -> Result<bool, AvailableClockError> { | ||
| Ok(matches!(family, ClockFamily::AvailableTime)) | ||
| } | ||
|
|
||
| /// Refuse to treat event time as availability time. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Always returns [`AvailableClockError::EventTimeIsNotAvailableTime`]. | ||
| pub fn refuse_event_time_as_available() -> Result<(), AvailableClockError> { | ||
| Err(AvailableClockError::EventTimeIsNotAvailableTime) | ||
| } | ||
|
|
||
| /// Refuse to treat system time as availability time. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Always returns [`AvailableClockError::SystemTimeIsNotAvailableTime`]. | ||
| pub fn refuse_system_time_as_available() -> Result<(), AvailableClockError> { | ||
| Err(AvailableClockError::SystemTimeIsNotAvailableTime) | ||
| } | ||
|
|
||
| /// Fraction of recovered availability flags that match known truth. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`AvailableClockError::InvalidAvailabilityPayload`] when either | ||
| /// slice is empty or the lengths differ. | ||
| pub fn eligibility_recovery_rate( | ||
| truth: &[bool], | ||
| decided: &[bool], | ||
| ) -> Result<f64, AvailableClockError> { | ||
| if truth.is_empty() || truth.len() != decided.len() { | ||
| return Err(AvailableClockError::InvalidAvailabilityPayload); | ||
| } | ||
| 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
+55
to
+64
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: Branch coverage of the
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ | ||
| ClockFamily, eligibility_recovery_rate, refuse_event_time_as_available, | ||
| refuse_system_time_as_available, stamp_is_available, | ||
| }; | ||
| use crate::AvailableClockError; | ||
|
|
||
| #[test] | ||
| fn local_branches_cover_families_and_payloads() { | ||
| assert!(stamp_is_available(ClockFamily::AvailableTime).expect("available")); | ||
| assert!(!stamp_is_available(ClockFamily::EventTime).expect("event")); | ||
| assert!(!stamp_is_available(ClockFamily::SystemTime).expect("system")); | ||
| assert_eq!( | ||
| refuse_event_time_as_available(), | ||
| Err(AvailableClockError::EventTimeIsNotAvailableTime) | ||
| ); | ||
| assert_eq!( | ||
| refuse_system_time_as_available(), | ||
| Err(AvailableClockError::SystemTimeIsNotAvailableTime) | ||
| ); | ||
| let matched = eligibility_recovery_rate(&[true], &[true]).expect("rate"); | ||
| assert!((matched - 1.0).abs() < f64::EPSILON); | ||
| assert_eq!( | ||
| eligibility_recovery_rate(&[], &[]), | ||
| Err(AvailableClockError::InvalidAvailabilityPayload) | ||
| ); | ||
| assert_eq!( | ||
| eligibility_recovery_rate(&[true], &[]), | ||
| Err(AvailableClockError::InvalidAvailabilityPayload) | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| //! Fail-closed available-clock errors. | ||
|
|
||
| use std::fmt; | ||
|
|
||
| /// A fail-closed available-clock error. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum AvailableClockError { | ||
| /// Event time was treated as availability time. | ||
| EventTimeIsNotAvailableTime, | ||
| /// System time was treated as availability time. | ||
| SystemTimeIsNotAvailableTime, | ||
| /// A recovery slice was empty or length-mismatched. | ||
| InvalidAvailabilityPayload, | ||
| } | ||
|
|
||
| impl fmt::Display for AvailableClockError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| let message = match self { | ||
| Self::EventTimeIsNotAvailableTime => "event time is not availability time", | ||
| Self::SystemTimeIsNotAvailableTime => "system time is not availability time", | ||
| Self::InvalidAvailabilityPayload => "invalid available-clock payload", | ||
| }; | ||
| formatter.write_str(message) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for AvailableClockError {} | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::AvailableClockError; | ||
|
|
||
| #[test] | ||
| fn error_messages_are_stable() { | ||
| for (error, message) in [ | ||
| ( | ||
| AvailableClockError::EventTimeIsNotAvailableTime, | ||
| "event time is not availability time", | ||
| ), | ||
| ( | ||
| AvailableClockError::SystemTimeIsNotAvailableTime, | ||
| "system time is not availability time", | ||
| ), | ||
| ( | ||
| AvailableClockError::InvalidAvailabilityPayload, | ||
| "invalid available-clock payload", | ||
| ), | ||
| ] { | ||
| assert_eq!(error.to_string(), message); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| #![forbid(unsafe_code)] | ||
| #![deny(missing_docs)] | ||
| #![allow(clippy::cast_precision_loss)] | ||
| //! Availability time cannot be replaced by event or system time. | ||
| //! | ||
| //! Historical eligibility uses availability versus knowledge cutoff. Event | ||
| //! time and system time are not substitutes (ADR 0002). | ||
|
|
||
| mod clock; | ||
| mod error; | ||
|
|
||
| /// Closed vocabulary of clocks that must not be confused with availability. | ||
| pub use clock::ClockFamily; | ||
| /// Fraction of recovered availability flags that match known truth. | ||
| pub use clock::eligibility_recovery_rate; | ||
| /// Refuse to treat event time as availability time. | ||
| pub use clock::refuse_event_time_as_available; | ||
| /// Refuse to treat system time as availability time. | ||
| pub use clock::refuse_system_time_as_available; | ||
| /// Return whether a stamp is on the availability clock. | ||
| pub use clock::stamp_is_available; | ||
| /// Fail-closed available-clock errors. | ||
| pub use error::AvailableClockError; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| //! Event and system time cannot stand in for availability. | ||
|
|
||
| use available_clock::{ | ||
| AvailableClockError, ClockFamily, eligibility_recovery_rate, refuse_event_time_as_available, | ||
| refuse_system_time_as_available, stamp_is_available, | ||
| }; | ||
|
|
||
| #[test] | ||
| fn event_and_system_time_cannot_stand_in_for_availability() { | ||
| assert_eq!( | ||
| refuse_event_time_as_available(), | ||
| Err(AvailableClockError::EventTimeIsNotAvailableTime) | ||
| ); | ||
| assert_eq!( | ||
| refuse_system_time_as_available(), | ||
| Err(AvailableClockError::SystemTimeIsNotAvailableTime) | ||
| ); | ||
| assert!(stamp_is_available(ClockFamily::AvailableTime).expect("available")); | ||
| assert!(!stamp_is_available(ClockFamily::EventTime).expect("event")); | ||
| assert!(!stamp_is_available(ClockFamily::SystemTime).expect("system")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn recovered_availability_stamps_match_known_truth_better_than_system_stand_in() { | ||
| let truth = [ | ||
| ClockFamily::AvailableTime, | ||
| ClockFamily::AvailableTime, | ||
| ClockFamily::AvailableTime, | ||
| ]; | ||
| let recovered = truth; | ||
| let collapsed = [ | ||
| ClockFamily::SystemTime, | ||
| ClockFamily::SystemTime, | ||
| ClockFamily::SystemTime, | ||
| ]; | ||
| let recovered_flags = [ | ||
| stamp_is_available(recovered[0]).expect("r0"), | ||
| stamp_is_available(recovered[1]).expect("r1"), | ||
| stamp_is_available(recovered[2]).expect("r2"), | ||
| ]; | ||
| let collapsed_flags = [ | ||
| stamp_is_available(collapsed[0]).expect("c0"), | ||
| stamp_is_available(collapsed[1]).expect("c1"), | ||
| stamp_is_available(collapsed[2]).expect("c2"), | ||
| ]; | ||
| let truth_flags = [true, true, true]; | ||
| let recovered_rate = eligibility_recovery_rate(&truth_flags, &recovered_flags).expect("ok"); | ||
| let collapsed_rate = eligibility_recovery_rate(&truth_flags, &collapsed_flags).expect("bad"); | ||
| let expected = { | ||
| let mut matches = 0_u32; | ||
| for (truth_flag, decided_flag) in truth_flags.iter().zip(recovered_flags.iter()) { | ||
| if truth_flag == decided_flag { | ||
| matches += 1; | ||
| } | ||
| } | ||
| f64::from(matches) / f64::from(u32::try_from(truth_flags.len()).expect("len")) | ||
| }; | ||
| assert!((recovered_rate - expected).abs() < f64::EPSILON); | ||
| assert!(recovered_rate > collapsed_rate); | ||
| } | ||
|
Comment on lines
+24
to
+60
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: Comparison test uses all-true truth which trivially favors availability recovery The contract test Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| #[test] | ||
| fn empty_or_mismatched_eligibility_payloads_fail_closed() { | ||
| assert_eq!( | ||
| eligibility_recovery_rate(&[], &[]), | ||
| Err(AvailableClockError::InvalidAvailabilityPayload) | ||
| ); | ||
| assert_eq!( | ||
| eligibility_recovery_rate(&[true], &[]), | ||
| Err(AvailableClockError::InvalidAvailabilityPayload) | ||
| ); | ||
| assert_eq!( | ||
| eligibility_recovery_rate(&[true, false], &[true]), | ||
| Err(AvailableClockError::InvalidAvailabilityPayload) | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| //! Integration contract for the `available_clock` package identity. | ||
|
|
||
| #[test] | ||
| fn package_identity_is_stable() { | ||
| let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); | ||
| assert_eq!(observed, "available_clock"); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # Availability-clock identity (doctoring) | ||
|
|
||
| ## Scope | ||
|
|
||
| `available_clock` keeps availability time distinct from event time and | ||
| system time. Recovery is the computed share of availability stamps that | ||
| match known truth. | ||
|
|
||
| This slice does not persist clocks, replace `temporal_core`, or recreate | ||
| `document_clocks`. | ||
|
|
||
| ## Authority | ||
|
|
||
| ### Normative TEPP contract | ||
|
|
||
| - `docs/adr/0002-six-clock-temporal-semantics.md` — availability time is | ||
| the time evidence became usable; it is not event time or system time. | ||
| - Historical analyses may not treat record time as the moment evidence | ||
| was available. | ||
|
|
||
| ### Supporting literature | ||
|
|
||
| Snodgrass (2000) separates valid time from transaction time. Availability | ||
| is a third TEPP clock: when the analyst could use the evidence. Neither | ||
| valid time nor transaction time is a substitute. | ||
|
|
||
| Snodgrass, R. T. (2000). *Developing time-oriented database applications | ||
| in SQL*. Morgan Kaufmann. |
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: Doc says "either slice is empty" but only truth is checked
The rustdoc for
eligibility_recovery_rate(clock.rs) states the error is returned when "either slice is empty or the lengths differ", but the guard at clock.rs only checkstruth.is_empty(). This is not a behavioral bug: iftruthis non-empty whiledecidedis empty, thetruth.len() != decided.len()check catches it, and if both are empty thetruth.is_empty()check catches it. So all documented failure cases are still handled; only the wording is slightly imprecise.Was this helpful? React with 👍 or 👎 to provide feedback.