epiphany/crates/epiphany-core/src/time.rs

821 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Time and duration primitives (Chapter 3).
//!
//! The core has **two clocks** (Chapter 3 §"Design Principles"): musical time,
//! measured in exact whole-note rationals ([`RationalTime`]), and wall-clock
//! time, measured in fixed-point nanoseconds ([`WallClockTime`]). Position and
//! duration are *distinct types* whose algebra is enforced at the type level
//! ([`MusicalPosition`] + [`MusicalDuration`] → [`MusicalPosition`];
//! position position → duration; position + position is not defined).
//!
//! Exactness is non-negotiable: musical time is an exact rational, never a
//! float (Chapter 3; Appendix D §"Exact and Quantized Representations"). The
//! recommended inline-or-promoted representation packs the common case and
//! promotes to arbitrary precision on overflow, with no observable behavioural
//! difference (Chapter 3 §"Promotion and Demotion").
use core::num::NonZeroU32;
use core::ops::{Add, Sub};
use std::sync::Arc;
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, DecodeError};
use num_bigint::{BigInt, Sign};
use num_rational::BigRational;
use num_traits::{Signed, Zero};
use crate::ids::{EventId, MeasureId, RegionId};
/// An exact rational musical-time value: a [`MusicalPosition`] or
/// [`MusicalDuration`] before the newtype distinction is applied (Chapter 3
/// §"The Rational Time Type"). The unit is the **whole note**: a quarter note
/// is `1/4`, a triplet eighth is `1/12`.
///
/// Representation is inline-or-promoted (Chapter 3 §"Recommended
/// Implementation"): the inline [`SmallRational`] (`i32` numerator,
/// `NonZeroU32` denominator) covers the overwhelmingly common case; arithmetic
/// that exceeds it silently promotes to an [`Arc`]-shared [`BigRational`].
///
/// **Canonical-form invariant.** A value is [`RationalTime::Small`] *if and
/// only if* its normalized numerator fits `i32` and its denominator fits a
/// nonzero `u32`. Every constructor and operation re-establishes this, so two
/// numerically-equal values always share a variant and demotion is never
/// observable (Chapter 3 §"Promotion and Demotion").
#[derive(Clone)]
pub enum RationalTime {
/// Inline case: fits in 8 bytes, always normalized.
Small(SmallRational),
/// Promoted case: arbitrary-precision rational, used only when arithmetic
/// overflows the inline range.
Large(Arc<BigRational>),
}
/// The inline rational: `i32` numerator over a `NonZeroU32` denominator,
/// always normalized so `gcd(|numerator|, denominator) == 1` (Chapter 3).
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct SmallRational {
numerator: i32,
denominator: NonZeroU32,
}
impl SmallRational {
/// The numerator.
#[inline]
pub fn numerator(self) -> i32 {
self.numerator
}
/// The (strictly positive) denominator.
#[inline]
pub fn denominator(self) -> u32 {
self.denominator.get()
}
}
impl RationalTime {
/// The additive identity, `0/1`.
pub fn zero() -> Self {
RationalTime::Small(SmallRational {
numerator: 0,
denominator: NonZeroU32::new(1).unwrap(),
})
}
/// The multiplicative identity and the duration of a whole note, `1/1`.
pub fn one() -> Self {
RationalTime::from_int(1)
}
/// An integer count of whole notes.
pub fn from_int(n: i32) -> Self {
RationalTime::Small(SmallRational {
numerator: n,
denominator: NonZeroU32::new(1).unwrap(),
})
}
/// Constructs `numerator / denominator`, normalized. Returns `None` if the
/// denominator is zero (the only non-representable input; magnitude is
/// handled by promotion).
pub fn new(numerator: i64, denominator: i64) -> Option<Self> {
if denominator == 0 {
return None;
}
Some(Self::from_big(BigRational::new(
BigInt::from(numerator),
BigInt::from(denominator),
)))
}
/// Builds from a (reduced or unreduced) [`BigRational`], demoting to
/// [`RationalTime::Small`] when the normalized value fits the inline range.
/// This is the single chokepoint that maintains the canonical-form
/// invariant.
pub(crate) fn from_big(value: BigRational) -> Self {
// `BigRational` keeps the denominator positive and the fraction
// reduced, so the sign lives on the numerator.
let numer = value.numer();
let denom = value.denom();
if let (Some(n), Some(d)) = (bigint_to_i32(numer), bigint_to_u32(denom)) {
if let Some(d) = NonZeroU32::new(d) {
return RationalTime::Small(SmallRational {
numerator: n,
denominator: d,
});
}
}
RationalTime::Large(Arc::new(value))
}
/// The value as a [`BigRational`] (allocates for the inline case; used on
/// the slow arithmetic path and for canonical encoding).
pub(crate) fn to_big(&self) -> BigRational {
match self {
RationalTime::Small(s) => {
BigRational::new(BigInt::from(s.numerator), BigInt::from(s.denominator.get()))
}
RationalTime::Large(b) => (**b).clone(),
}
}
/// Whether the value is exactly zero.
pub fn is_zero(&self) -> bool {
match self {
RationalTime::Small(s) => s.numerator == 0,
RationalTime::Large(b) => b.is_zero(),
}
}
/// Whether the value is strictly negative.
pub fn is_negative(&self) -> bool {
match self {
RationalTime::Small(s) => s.numerator < 0,
RationalTime::Large(b) => b.is_negative(),
}
}
/// A lossy `f64` approximation. For *advisory* use only — tempo conversion,
/// spacing hints, diagnostics — never canonical state (musical time is the
/// exact rational; Appendix D §"Exact and Quantized Representations").
pub fn to_f64(&self) -> f64 {
match self {
RationalTime::Small(s) => s.numerator as f64 / s.denominator.get() as f64,
RationalTime::Large(b) => {
use num_traits::ToPrimitive;
b.to_f64().unwrap_or(f64::NAN)
}
}
}
/// Exact addition.
pub fn add(&self, other: &Self) -> Self {
if let (RationalTime::Small(a), RationalTime::Small(b)) = (self, other) {
// Fast path: a/b + c/d in widened i128, then fit-or-promote.
let (n, d) = (a.numerator as i128, a.denominator.get() as i128);
let (n2, d2) = (b.numerator as i128, b.denominator.get() as i128);
if let Some(r) = small_from_i128(n * d2 + n2 * d, d * d2) {
return r;
}
}
RationalTime::from_big(self.to_big() + other.to_big())
}
/// Exact subtraction.
pub fn sub(&self, other: &Self) -> Self {
if let (RationalTime::Small(a), RationalTime::Small(b)) = (self, other) {
let (n, d) = (a.numerator as i128, a.denominator.get() as i128);
let (n2, d2) = (b.numerator as i128, b.denominator.get() as i128);
if let Some(r) = small_from_i128(n * d2 - n2 * d, d * d2) {
return r;
}
}
RationalTime::from_big(self.to_big() - other.to_big())
}
/// Exact multiplication (used to scale durations by a tuplet ratio, etc.).
pub fn mul(&self, other: &Self) -> Self {
if let (RationalTime::Small(a), RationalTime::Small(b)) = (self, other) {
let n = a.numerator as i128 * b.numerator as i128;
let d = a.denominator.get() as i128 * b.denominator.get() as i128;
if let Some(r) = small_from_i128(n, d) {
return r;
}
}
RationalTime::from_big(self.to_big() * other.to_big())
}
/// Sums a sequence of rationals exactly (left fold). Useful for the
/// tuplet- and decomposition-sum invariants (Chapter 5).
pub fn sum<'a, I: IntoIterator<Item = &'a RationalTime>>(iter: I) -> RationalTime {
let mut acc = RationalTime::zero();
for r in iter {
acc = acc.add(r);
}
acc
}
}
/// Reduces `num/den` (with arbitrary-sign `den`) and returns it as a
/// [`RationalTime::Small`] iff it fits the inline range; otherwise `None`,
/// signalling the caller to take the [`BigRational`] path.
fn small_from_i128(mut num: i128, mut den: i128) -> Option<RationalTime> {
if den == 0 {
return None;
}
if den < 0 {
num = -num;
den = -den;
}
let g = {
let mut a = num.unsigned_abs();
let mut b = den as u128;
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a.max(1)
} as i128;
num /= g;
den /= g;
let n: i32 = i32::try_from(num).ok()?;
let d: u32 = u32::try_from(den).ok()?;
Some(RationalTime::Small(SmallRational {
numerator: n,
denominator: NonZeroU32::new(d)?,
}))
}
fn bigint_to_i32(b: &BigInt) -> Option<i32> {
i32::try_from(b.clone()).ok()
}
fn bigint_to_u32(b: &BigInt) -> Option<u32> {
u32::try_from(b.clone()).ok()
}
impl PartialEq for RationalTime {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
// Canonical-form invariant: equal values share a variant, so the
// fast inline comparison is exact for the common case.
(RationalTime::Small(a), RationalTime::Small(b)) => a == b,
_ => self.cmp(other) == core::cmp::Ordering::Equal,
}
}
}
impl Eq for RationalTime {}
impl Ord for RationalTime {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
if let (RationalTime::Small(a), RationalTime::Small(b)) = (self, other) {
// Cross-multiply in i128 to avoid overflow (Chapter 3 §"Equality
// and Ordering"). Denominators are positive, so the inequality
// direction is preserved.
let lhs = a.numerator as i128 * b.denominator.get() as i128;
let rhs = b.numerator as i128 * a.denominator.get() as i128;
return lhs.cmp(&rhs);
}
self.to_big().cmp(&other.to_big())
}
}
impl PartialOrd for RationalTime {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl core::hash::Hash for RationalTime {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
// Within each variant the normalized form is unique, and the
// canonical-form invariant guarantees numerically-equal values never
// straddle the variant boundary, so per-variant hashing is consistent
// with `Eq`.
match self {
RationalTime::Small(s) => {
state.write_u8(0);
state.write_i32(s.numerator);
state.write_u32(s.denominator.get());
}
RationalTime::Large(b) => {
state.write_u8(1);
let (sign, bytes) = b.numer().to_bytes_be();
state.write_i8(match sign {
Sign::Minus => -1,
Sign::NoSign => 0,
Sign::Plus => 1,
});
state.write(&bytes);
state.write(&b.denom().to_bytes_be().1);
}
}
}
}
impl core::fmt::Debug for RationalTime {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
RationalTime::Small(s) => write!(f, "{}/{}", s.numerator, s.denominator.get()),
RationalTime::Large(b) => write!(f, "{}/{} (large)", b.numer(), b.denom()),
}
}
}
impl Default for RationalTime {
fn default() -> Self {
RationalTime::zero()
}
}
/// Canonical, arbitrary-precision, reversible byte form for a rational: the
/// numerator's sign and big-endian magnitude (length-prefixed), then the
/// positive denominator's big-endian magnitude (length-prefixed). The value is
/// always reduced first, so equal rationals encode to equal bytes (Appendix D
/// §"Canonical serialization determinism"). RATIFIED by Pass 11 (item 1.7,
/// P11-4): this primitive layout is now normative in core_spec §"Binary Format
/// Companion", Requirement `req:format:rationaltime-encoding`; the full
/// composite wire format remains the Binary Format companion's (Agent J), which
/// inherits the ratified convention baseline (`req:format:codec-conventions`).
impl CanonicalEncode for RationalTime {
fn encode_canonical(&self, out: &mut Vec<u8>) {
let big = self.to_big();
let (sign, numer_mag) = big.numer().to_bytes_be();
let denom_mag = big.denom().to_bytes_be().1;
out.push(match sign {
Sign::Minus => 2,
Sign::NoSign => 0,
Sign::Plus => 1,
});
out.extend_from_slice(&(numer_mag.len() as u32).to_le_bytes());
out.extend_from_slice(&numer_mag);
out.extend_from_slice(&(denom_mag.len() as u32).to_le_bytes());
out.extend_from_slice(&denom_mag);
}
}
impl CanonicalDecode for RationalTime {
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
let mut cur = bytes;
let take = |cur: &mut &[u8], n: usize| -> Result<Vec<u8>, DecodeError> {
if cur.len() < n {
return Err(DecodeError::UnexpectedLength {
expected: n,
actual: cur.len(),
});
}
let (head, tail) = cur.split_at(n);
*cur = tail;
Ok(head.to_vec())
};
let sign_byte = take(&mut cur, 1)?[0];
let sign = match sign_byte {
0 => Sign::NoSign,
1 => Sign::Plus,
2 => Sign::Minus,
_ => return Err(DecodeError::MalformedDomainTag),
};
let numer_len = u32::from_le_bytes(take(&mut cur, 4)?.try_into().unwrap()) as usize;
let numer_mag = take(&mut cur, numer_len)?;
let denom_len = u32::from_le_bytes(take(&mut cur, 4)?.try_into().unwrap()) as usize;
let denom_mag = take(&mut cur, denom_len)?;
if !cur.is_empty() {
return Err(DecodeError::UnexpectedLength {
expected: bytes.len() - cur.len(),
actual: bytes.len(),
});
}
let numer = BigInt::from_bytes_be(sign, &numer_mag);
let denom = BigInt::from_bytes_be(Sign::Plus, &denom_mag);
if denom.is_zero() {
return Err(DecodeError::MalformedDomainTag);
}
Ok(RationalTime::from_big(BigRational::new(numer, denom)))
}
}
/// A point in musical time, relative to the origin of a time region
/// (Chapter 3 §"Position and Duration as Distinct Types"). Wraps a
/// [`RationalTime`]; adding two positions is intentionally not defined.
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Default)]
pub struct MusicalPosition(pub RationalTime);
/// A span of musical time (Chapter 3). Wraps a [`RationalTime`].
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Default)]
pub struct MusicalDuration(pub RationalTime);
impl MusicalPosition {
/// The region origin, `0`.
pub fn origin() -> Self {
MusicalPosition(RationalTime::zero())
}
/// The underlying rational.
pub fn rational(&self) -> &RationalTime {
&self.0
}
}
impl MusicalDuration {
/// The zero-length duration.
pub fn zero() -> Self {
MusicalDuration(RationalTime::zero())
}
/// A whole note (`1/1`).
pub fn whole() -> Self {
MusicalDuration(RationalTime::one())
}
/// The underlying rational.
pub fn rational(&self) -> &RationalTime {
&self.0
}
/// Whether the duration is strictly positive (the usual well-formedness
/// condition for a sounding event).
pub fn is_positive(&self) -> bool {
!self.0.is_zero() && !self.0.is_negative()
}
/// Sums a sequence of durations exactly.
pub fn sum<'a, I: IntoIterator<Item = &'a MusicalDuration>>(iter: I) -> MusicalDuration {
MusicalDuration(RationalTime::sum(iter.into_iter().map(|d| &d.0)))
}
}
// The type-level algebra of Chapter 3 §"Position and Duration as Distinct
// Types". `MusicalPosition + MusicalPosition` is deliberately absent.
impl Add<MusicalDuration> for MusicalPosition {
type Output = MusicalPosition;
fn add(self, rhs: MusicalDuration) -> MusicalPosition {
MusicalPosition(self.0.add(&rhs.0))
}
}
impl Add<MusicalDuration> for MusicalDuration {
type Output = MusicalDuration;
fn add(self, rhs: MusicalDuration) -> MusicalDuration {
MusicalDuration(self.0.add(&rhs.0))
}
}
impl Sub<MusicalPosition> for MusicalPosition {
type Output = MusicalDuration;
fn sub(self, rhs: MusicalPosition) -> MusicalDuration {
MusicalDuration(self.0.sub(&rhs.0))
}
}
impl Sub<MusicalDuration> for MusicalDuration {
type Output = MusicalDuration;
fn sub(self, rhs: MusicalDuration) -> MusicalDuration {
MusicalDuration(self.0.sub(&rhs.0))
}
}
macro_rules! delegate_canon {
($name:ident) => {
impl CanonicalEncode for $name {
#[inline]
fn encode_canonical(&self, out: &mut Vec<u8>) {
self.0.encode_canonical(out);
}
}
impl CanonicalDecode for $name {
#[inline]
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
Ok($name(RationalTime::decode_canonical(bytes)?))
}
}
};
}
delegate_canon!(MusicalPosition);
delegate_canon!(MusicalDuration);
/// A point in wall-clock time, in nanoseconds from a region origin (Chapter 3
/// §"Wall-Clock Time"). 64-bit signed: range ±~292 years. Floating-point
/// wall-clock time is forbidden in stored data.
#[derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Default)]
pub struct WallClockTime(pub i64);
/// A span of wall-clock time, in nanoseconds.
#[derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Default)]
pub struct WallClockDuration(pub i64);
impl WallClockTime {
/// Canonical little-endian bytes (8, `i64`), matching the integer
/// convention of [`epiphany_determinism::QuantizedCoord`].
#[inline]
pub fn to_le_bytes(self) -> [u8; 8] {
self.0.to_le_bytes()
}
}
impl CanonicalEncode for WallClockTime {
#[inline]
fn encode_canonical(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.0.to_le_bytes());
}
}
impl CanonicalDecode for WallClockTime {
#[inline]
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
let arr: [u8; 8] = bytes
.try_into()
.map_err(|_| DecodeError::UnexpectedLength {
expected: 8,
actual: bytes.len(),
})?;
Ok(WallClockTime(i64::from_le_bytes(arr)))
}
}
impl CanonicalEncode for WallClockDuration {
#[inline]
fn encode_canonical(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.0.to_le_bytes());
}
}
impl CanonicalDecode for WallClockDuration {
#[inline]
fn decode_canonical(bytes: &[u8]) -> Result<Self, DecodeError> {
let arr: [u8; 8] = bytes
.try_into()
.map_err(|_| DecodeError::UnexpectedLength {
expected: 8,
actual: bytes.len(),
})?;
Ok(WallClockDuration(i64::from_le_bytes(arr)))
}
}
/// Which boundary of a measure an anchor points at.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum MeasurePosition {
Start,
End,
}
/// Which edge of a region an anchor points at.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum RegionEdge {
Start,
End,
}
/// An offset applied to an anchor target (Chapter 3 §"Time Anchors"). The
/// admissible variant is constrained by the target's enclosing region's time
/// model — see [`OffsetKind`] and invariant 9 in the `invariants` module.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum AnchorOffset {
/// Offset in musical time. Valid for targets in metric regions (and
/// musical-discipline aleatoric regions).
Musical(MusicalDuration),
/// Offset in wall-clock time. Valid for targets in proportional regions
/// (and wall-clock-discipline aleatoric regions).
WallClock(WallClockDuration),
/// No offset; the anchor refers to the target's reference point exactly.
/// Valid in any region.
Zero,
}
/// The clock an [`AnchorOffset`] is expressed in, used to check it against a
/// region's time model (Chapter 3; invariant 9).
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum OffsetKind {
Musical,
WallClock,
Zero,
}
impl AnchorOffset {
/// The clock this offset is expressed in.
pub fn kind(&self) -> OffsetKind {
match self {
AnchorOffset::Musical(_) => OffsetKind::Musical,
AnchorOffset::WallClock(_) => OffsetKind::WallClock,
AnchorOffset::Zero => OffsetKind::Zero,
}
}
}
/// A stored reference to a point in time (Chapter 3 §"Time Anchors"). Stored
/// references to *external* time points must anchor to identified objects plus
/// offsets, never to absolute positions that could shift under edits.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum TimeAnchor {
/// Anchored to a specific event. Survives edits that do not delete it.
Event { id: EventId, offset: AnchorOffset },
/// Anchored to a measure boundary. Survives measure reordering.
Measure {
id: MeasureId,
position: MeasurePosition,
offset: AnchorOffset,
},
/// Anchored to the start or end of a region.
Region {
id: RegionId,
edge: RegionEdge,
offset: AnchorOffset,
},
/// Anchored to absolute wall-clock time. Used for film and audio sync.
WallClock { time: WallClockTime },
}
impl TimeAnchor {
/// The offset of this anchor, if it has one (a [`TimeAnchor::WallClock`]
/// anchor carries no separate offset; its position is absolute).
pub fn offset(&self) -> Option<&AnchorOffset> {
match self {
TimeAnchor::Event { offset, .. }
| TimeAnchor::Measure { offset, .. }
| TimeAnchor::Region { offset, .. } => Some(offset),
TimeAnchor::WallClock { .. } => None,
}
}
}
/// An event's position within its owning voice and region (Chapter 5
/// §"Event Position and Duration"). Unioned over the two clocks; the admissible
/// variant is fixed by the enclosing region's time model.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum EventPosition {
Musical(MusicalPosition),
WallClock(WallClockTime),
}
/// An event's duration (Chapter 5). Unioned over musical, wall-clock, and
/// indeterminate forms.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum EventDuration {
Musical(MusicalDuration),
WallClock(WallClockDuration),
Indeterminate(DurationBounds),
}
/// A concrete (non-indeterminate) duration in one of the two clocks
/// (Chapter 5). The bounds of an indeterminate duration are concrete, which
/// prevents recursive indeterminacy in the type system.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum ConcreteDuration {
Musical(MusicalDuration),
WallClock(WallClockDuration),
}
/// The clock a position/duration coordinate is expressed in, used to check it
/// against a region's time model (Chapter 5; invariant 4).
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum CoordinateKind {
Musical,
WallClock,
}
impl EventPosition {
/// The clock this position is expressed in.
pub fn kind(&self) -> CoordinateKind {
match self {
EventPosition::Musical(_) => CoordinateKind::Musical,
EventPosition::WallClock(_) => CoordinateKind::WallClock,
}
}
}
impl ConcreteDuration {
/// The clock this duration is expressed in.
pub fn kind(&self) -> CoordinateKind {
match self {
ConcreteDuration::Musical(_) => CoordinateKind::Musical,
ConcreteDuration::WallClock(_) => CoordinateKind::WallClock,
}
}
}
impl EventDuration {
/// The concrete clock of a determinate duration, or `None` for an
/// indeterminate one.
pub fn concrete_kind(&self) -> Option<CoordinateKind> {
match self {
EventDuration::Musical(_) => Some(CoordinateKind::Musical),
EventDuration::WallClock(_) => Some(CoordinateKind::WallClock),
EventDuration::Indeterminate(_) => None,
}
}
}
/// A bounded interval expressing an indeterminate duration (Chapter 5). Bounds
/// are [`ConcreteDuration`] so indeterminacy cannot recurse.
#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)]
pub struct DurationBounds {
pub lower: Option<ConcreteDuration>,
pub upper: Option<ConcreteDuration>,
}
/// An interval bound for an aleatoric event's start or end (Chapter 3
/// §"Aleatoric Time").
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum TimeBounds {
MusicalRange {
min: MusicalPosition,
max: MusicalPosition,
},
WallClockRange {
min: WallClockTime,
max: WallClockTime,
},
Unbounded,
}
/// Per-event interval bounds for an aleatoric region (Chapter 3 §"Aleatoric
/// Time"): an event may begin/end anywhere within the given windows.
#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)]
pub struct EventBounds {
pub start: Option<TimeBounds>,
pub end: Option<TimeBounds>,
}
#[cfg(test)]
mod tests {
use super::*;
fn r(n: i64, d: i64) -> RationalTime {
RationalTime::new(n, d).unwrap()
}
#[test]
fn rationals_normalize_and_compare_by_value() {
assert_eq!(r(2, 4), r(1, 2));
assert_eq!(r(-3, -6), r(1, 2));
assert!(r(1, 3) < r(1, 2));
assert!(r(-1, 2) < RationalTime::zero());
// 1/4 + 1/12 = 1/3 (the tuplet-eighth example, Chapter 3).
assert_eq!(r(1, 4).add(&r(1, 12)), r(1, 3));
// A quintuplet-sixteenth in a duplet-half: 1/2 * 1/2 * 1/5 = 1/20.
assert_eq!(r(1, 2).mul(&r(1, 2)).mul(&r(1, 5)), r(1, 20));
}
#[test]
fn arithmetic_promotes_then_stays_exact() {
// Denominators 999_999_937 (prime) and 999_999_893 (prime) multiply to
// ~1e18, far past u32; the result must promote and stay exact.
let a = r(1, 999_999_937);
let b = r(1, 999_999_893);
let sum = a.add(&b);
assert!(matches!(sum, RationalTime::Large(_)), "must promote");
// Cross-check against an independent BigRational computation.
let expect = BigRational::new(BigInt::from(1), BigInt::from(999_999_937i64))
+ BigRational::new(BigInt::from(1), BigInt::from(999_999_893i64));
assert_eq!(sum, RationalTime::from_big(expect));
// Subtracting back demotes to the inline value, unobservably.
let back = sum.sub(&b);
assert_eq!(back, a);
assert!(matches!(back, RationalTime::Small(_)), "must demote");
}
#[test]
fn equal_values_hash_equally_across_construction_paths() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let h = |x: &RationalTime| {
let mut s = DefaultHasher::new();
x.hash(&mut s);
s.finish()
};
assert_eq!(h(&r(2, 4)), h(&r(1, 2)));
assert_eq!(h(&r(6, 3)), h(&RationalTime::from_int(2)));
}
#[test]
fn position_duration_algebra_is_typed() {
let p = MusicalPosition(r(1, 2));
let d = MusicalDuration(r(1, 4));
let p2 = p.clone() + d.clone(); // position + duration -> position
assert_eq!(p2, MusicalPosition(r(3, 4)));
let span = p2 - p; // position - position -> duration
assert_eq!(span, MusicalDuration(r(1, 4)));
let dd = d.clone() + d; // duration + duration -> duration
assert_eq!(dd, MusicalDuration(r(1, 2)));
}
#[test]
fn rational_round_trips_canonically_including_large() {
for v in [
RationalTime::zero(),
r(1, 1),
r(-7, 12),
r(3, 1024),
r(1, 999_999_937).add(&r(1, 999_999_893)),
] {
let bytes = v.to_canonical_bytes();
let back = RationalTime::decode_canonical(&bytes).unwrap();
assert_eq!(back, v);
assert_eq!(back.to_canonical_bytes(), bytes, "re-encode byte-stable");
}
}
#[test]
fn equal_rationals_encode_identically() {
assert_eq!(r(2, 4).to_canonical_bytes(), r(1, 2).to_canonical_bytes());
assert_eq!(
r(6, 3).to_canonical_bytes(),
RationalTime::from_int(2).to_canonical_bytes()
);
}
#[test]
fn wallclock_round_trips() {
for v in [i64::MIN, -1, 0, 1, 1_000_000_000, i64::MAX] {
let t = WallClockTime(v);
assert_eq!(
WallClockTime::decode_canonical(&t.to_canonical_bytes()).unwrap(),
t
);
}
}
}