From 80b79a3ad53fa6a2fd3db6d975928d46f5f5fe58 Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Mon, 24 Nov 2025 17:01:30 -0800 Subject: [PATCH 01/11] Update to new ComputeNudgeWindow spec text --- src/builtins/core/duration/normalized.rs | 202 ++++++++++++++++++----- src/builtins/core/duration/tests.rs | 23 +++ src/options.rs | 41 +++++ 3 files changed, 228 insertions(+), 38 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index 26819f99d..d77c7b7a5 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -18,7 +18,8 @@ use crate::{ primitive::{DoubleDouble, FiniteF64}, provider::TimeZoneProvider, rounding::IncrementRounder, - Calendar, TemporalError, TemporalResult, TemporalUnwrap, NS_PER_DAY, NS_PER_DAY_NONZERO, + temporal_assert, Calendar, TemporalError, TemporalResult, TemporalUnwrap, NS_PER_DAY, + NS_PER_DAY_NONZERO, }; use super::{DateDuration, Duration, Sign}; @@ -395,18 +396,26 @@ struct NudgeRecord { expanded: bool, } +struct NudgeWindow { + r1: i128, + r2: i128, + start_epoch_ns: EpochNanoseconds, + end_epoch_ns: EpochNanoseconds, + start_duration: DateDuration, + end_duration: DateDuration, +} + impl InternalDurationRecord { - // TODO: Add assertion into impl. - // TODO: Add unit tests specifically for nudge_calendar_unit if possible. - fn nudge_calendar_unit( + /// + fn compute_nudge_window( &self, sign: Sign, origin_epoch_ns: EpochNanoseconds, - dest_epoch_ns: i128, dt: &PlainDateTime, time_zone: Option<(&TimeZone, &(impl TimeZoneProvider + ?Sized))>, // ??? options: ResolvedRoundingOptions, - ) -> TemporalResult { + additional_shift: bool, + ) -> TemporalResult { // NOTE: r2 may never be used...need to test. let (r1, r2, start_duration, end_duration) = match options.smallest_unit { // 1. If unit is "year", then @@ -417,11 +426,18 @@ impl InternalDurationRecord { options.increment.as_extended_increment(), )? .round(RoundingMode::Trunc); - // b. Let r1 be years. - let r1 = years; - // c. Let r2 be years + increment × sign. - let r2 = years - + i128::from(options.increment.get()) * i128::from(sign.as_sign_multiplier()); + let increment_x_sign = + i128::from(options.increment.get()) * i128::from(sign.as_sign_multiplier()); + // b. If additionalShift is false, then + let r1 = if !additional_shift { + // i. Let r1 be years. + years + } else { + // i. Let r1 be years + increment × sign. + years + increment_x_sign + }; + // c. Let r2 be r1 + increment × sign. + let r2 = r1 + increment_x_sign; // d. Let startDuration be ? CreateNormalizedDurationRecord(r1, 0, 0, 0, ZeroTimeDuration()). // e. Let endDuration be ? CreateNormalizedDurationRecord(r2, 0, 0, 0, ZeroTimeDuration()). ( @@ -449,11 +465,18 @@ impl InternalDurationRecord { options.increment.as_extended_increment(), )? .round(RoundingMode::Trunc); - // b. Let r1 be months. - let r1 = months; - // c. Let r2 be months + increment × sign. - let r2 = months - + i128::from(options.increment.get()) * i128::from(sign.as_sign_multiplier()); + let increment_x_sign = + i128::from(options.increment.get()) * i128::from(sign.as_sign_multiplier()); + // b. If additionalShift is false, then + let r1 = if !additional_shift { + // i. Let r1 be months. + months + } else { + // i. Let r1 be months + increment × sign. + months + increment_x_sign + }; + // c. Let r2 be r1 + increment × sign. + let r2 = r1 + increment_x_sign; // d. Let startDuration be ? CreateNormalizedDurationRecord(duration.[[Years]], r1, 0, 0, ZeroTimeDuration()). // e. Let endDuration be ? CreateNormalizedDurationRecord(duration.[[Years]], r2, 0, 0, ZeroTimeDuration()). ( @@ -602,6 +625,14 @@ impl InternalDurationRecord { } }; + // 5. Assert: If sign is 1, r1 ≥ 0 and r1 < r2. + // 6. Assert: If sign is -1, r1 ≤ 0 and r1 > r2. + // n.b. sign == 1 means nonnegative + crate::temporal_assert!( + (sign != Sign::Negative && r1 >= 0 && r1 < r2) + || (sign == Sign::Negative && r1 <= 0 && r1 > r2) + ); + let start_epoch_ns = if r1 == 0 { origin_epoch_ns } else { @@ -646,36 +677,132 @@ impl InternalDurationRecord { end.as_nanoseconds() }; - // TODO: look into handling asserts - // 13. If sign is 1, then - // a. Assert: startEpochNs ≤ destEpochNs ≤ endEpochNs. - // 14. Else, - // a. Assert: endEpochNs ≤ destEpochNs ≤ startEpochNs. - // 15. Assert: startEpochNs ≠ endEpochNs. + Ok(NudgeWindow { + r1, + r2, + start_epoch_ns, + end_epoch_ns, + start_duration, + end_duration, + }) + } + // TODO: Add assertion into impl. + // TODO: Add unit tests specifically for nudge_calendar_unit if possible. + fn nudge_calendar_unit( + &self, + sign: Sign, + origin_epoch_ns: EpochNanoseconds, + dest_epoch_ns: i128, + dt: &PlainDateTime, + time_zone: Option<(&TimeZone, &(impl TimeZoneProvider + ?Sized))>, // ??? + options: ResolvedRoundingOptions, + ) -> TemporalResult { + let dest_epoch_ns = EpochNanoseconds(dest_epoch_ns); + + // 1. Let didExpandCalendarUnit be false. + let mut did_expand_calendar_unit = false; + + // 2. Let nudgeWindow be ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, false). + let mut nudge_window = + self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, false)?; + + // 3. Let startEpochNs be nudgeWindow.[[StartEpochNs]]. + // 4. Let endEpochNs be nudgeWindow.[[EndEpochNs]]. + // (implicitly used) + + // 5. If sign is 1, then + if sign != Sign::Negative { + // a. If startEpochNs ≤ destEpochNs ≤ endEpochNs is false, then + if !(nudge_window.start_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.end_epoch_ns) + { + // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). + nudge_window = + self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, true)?; + // ii. Assert: nudgeWindow.[[StartEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[EndEpochNs]]. + temporal_assert!( + nudge_window.start_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.end_epoch_ns + ); + // iii. Set didExpandCalendarUnit to true. + did_expand_calendar_unit = true; + } + } else { + // a. If endEpochNs ≤ destEpochNs ≤ startEpochNs is false, then + if !(nudge_window.end_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.start_epoch_ns) + { + // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). + nudge_window = + self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, true)?; + // ii. Assert: nudgeWindow.[[EndEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[StartEpochNs]]. + temporal_assert!( + nudge_window.end_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.start_epoch_ns + ); + // iii. Set didExpandCalendarUnit to true. + did_expand_calendar_unit = true; + } + } + + // 7. Let r1 be nudgeWindow.[[R1]]. + // 8. Let r2 be nudgeWindow.[[R2]]. + // 9. Set startEpochNs to nudgeWindow.[[StartEpochNs]]. + // 10. Set endEpochNs to nudgeWindow.[[EndEpochNs]]. + // 11. Let startDuration be nudgeWindow.[[StartDuration]]. + // 12. Let endDuration be nudgeWindow.[[EndDuration]]. + + let NudgeWindow { + r1, + r2, + start_epoch_ns, + end_epoch_ns, + start_duration, + end_duration, + } = nudge_window; + + // 13. Assert: startEpochNs ≠ endEpochNs. + temporal_assert!(start_epoch_ns != end_epoch_ns); // TODO: Don't use f64 below ... // NOTE(nekevss): Step 12..13 could be problematic...need tests // and verify, or completely change the approach involved. // TODO(nekevss): Validate that the `f64` casts here are valid in all scenarios - // 16. Let progress be (destEpochNs - startEpochNs) / (endEpochNs - startEpochNs). - // 17. Let total be r1 + progress × increment × sign. - let progress = - (dest_epoch_ns - start_epoch_ns.0) as f64 / (end_epoch_ns.0 - start_epoch_ns.0) as f64; + // 14. Let progress be (destEpochNs - startEpochNs) / (endEpochNs - startEpochNs). + // 15. Let total be r1 + progress × increment × sign. + let progress = (dest_epoch_ns.0 - start_epoch_ns.0) as f64 + / (end_epoch_ns.0 - start_epoch_ns.0) as f64; let total = r1 as f64 + progress * options.increment.get() as f64 * f64::from(sign.as_sign_multiplier()); - // 14. NOTE: The above two steps cannot be implemented directly using floating-point arithmetic. + // 16. NOTE: The above two steps cannot be implemented directly using floating-point arithmetic. // This division can be implemented as if constructing Normalized Time Duration Records for the denominator // and numerator of total and performing one division operation with a floating-point result. - // 15. Let roundedUnit be ApplyUnsignedRoundingMode(total, r1, r2, unsignedRoundingMode). - let rounded_unit = - IncrementRounder::from_signed_num(total, options.increment.as_extended_increment())? - .round(options.rounding_mode); - - // 16. If roundedUnit - total < 0, let roundedSign be -1; else let roundedSign be 1. - // 19. Return Duration Nudge Result Record { [[Duration]]: resultDuration, [[Total]]: total, [[NudgedEpochNs]]: nudgedEpochNs, [[DidExpandCalendarUnit]]: didExpandCalendarUnit }. - // 17. If roundedSign = sign, then - if rounded_unit == r2 { + // 17. Assert: 0 ≤ progress ≤ 1. + temporal_assert!((0. ..=1.).contains(&progress)); + // 18. If sign < 0, let isNegative be negative; else let isNegative be positive. + // (used implicitly) + + // 19. Let unsignedRoundingMode be GetUnsignedRoundingMode(roundingMode, isNegative). + // n.b. get_unsigned_round_mode takes is_positive, but it actually cares about nonnegative + let unsigned_rounding_mode = options + .rounding_mode + .get_unsigned_round_mode(sign != Sign::Negative); + + // 20. If progress = 1, then + let rounded_unit = if progress == 1. { + // a. Let roundedUnit be abs(r2). + r2.abs() + } else { + // a. Assert: abs(r1) ≤ abs(total) < abs(r2). + temporal_assert!(r1.abs() as f64 <= total.abs() && total.abs() < r2.abs() as f64); + // b. Let roundedUnit be ApplyUnsignedRoundingMode(abs(total), abs(r1), abs(r2), unsignedRoundingMode). + // TODO: what happens to r2 here? + unsigned_rounding_mode.apply(total.abs(), r1.abs(), r2.abs()) + }; + + // 22. If roundedUnit is abs(r2), then + if rounded_unit == r2.abs() { // a. Let didExpandCalendarUnit be true. // b. Let resultDuration be endDuration. // c. Let nudgedEpochNs be endEpochNs. @@ -687,14 +814,13 @@ impl InternalDurationRecord { }) // 18. Else, } else { - // a. Let didExpandCalendarUnit be false. // b. Let resultDuration be startDuration. // c. Let nudgedEpochNs be startEpochNs. Ok(NudgeRecord { normalized: InternalDurationRecord::new(start_duration, TimeDuration::default())?, total: Some(FiniteF64::try_from(total)?), nudge_epoch_ns: start_epoch_ns.0, - expanded: false, + expanded: did_expand_calendar_unit, }) } } diff --git a/src/builtins/core/duration/tests.rs b/src/builtins/core/duration/tests.rs index c5931d984..5b68fe9f2 100644 --- a/src/builtins/core/duration/tests.rs +++ b/src/builtins/core/duration/tests.rs @@ -452,3 +452,26 @@ fn total_full_numeric_precision() { let d = Duration::new(0, 0, 0, 0, 0, 0, 0, MAX_SAFE_INTEGER + 1, 1999, 0).unwrap(); assert_eq!(d.total(Unit::Millisecond, None).unwrap(), 9007199254740994.); } + +/// Test for https://github.com/tc39/proposal-temporal/pull/3172/ +/// +/// test262: built-ins/Temporal/Duration/prototype/total/rounding-window +#[test] +#[cfg(feature = "compiled_data")] +fn test_nudge_relative_date_total() { + use crate::Calendar; + use crate::PlainDate; + let d = Duration::new(1, 0, 0, 0, 1, 0, 0, 0, 0, 0).unwrap(); + let relative = PlainDate::new(2020, 2, 29, Calendar::ISO).unwrap(); + assert_eq!( + d.total(Unit::Year, Some(relative.into())).unwrap(), + 1.0001141552511414 + ); + + let d = Duration::new(0, 1, 0, 0, 10, 0, 0, 0, 0, 0).unwrap(); + let relative = PlainDate::new(2020, 1, 31, Calendar::ISO).unwrap(); + assert_eq!( + d.total(Unit::Month, Some(relative.into())).unwrap(), + 1.0134408602150538 + ); +} diff --git a/src/options.rs b/src/options.rs index c7f6c025a..1f8a9ec69 100644 --- a/src/options.rs +++ b/src/options.rs @@ -847,6 +847,47 @@ impl RoundingMode { } } +impl UnsignedRoundingMode { + /// + pub(crate) fn apply(self, x: f64, r1: i128, r2: i128) -> i128 { + // 1. If x = r1, return r1. + if r1 as f64 == x { + return r1; + } + // 4. If unsignedRoundingMode is zero, return r1. + if self == UnsignedRoundingMode::Zero { + return r1; + } else if self == UnsignedRoundingMode::Infinity { + return r2; + } + // 6. Let d1 be x – r1. + // 7. Let d2 be r2 – x. + let d1 = x - r1 as f64; + let d2 = r2 as f64 - x; + if d1 < d2 { + return r1; + } else if d1 > d2 { + return r2; + } + match self { + UnsignedRoundingMode::HalfZero => r1, + UnsignedRoundingMode::HalfInfinity => r2, + // HalfEven + _ => { + // 14. Let cardinality be (r1 / (r2 – r1)) modulo 2. + let diff = r2 - r1; + let cardinality = (r1 as f64 / diff as f64) % 2.; + // 15. If cardinality = 0, return r1. + if cardinality == 0. { + r1 + } else { + r2 + } + } + } + } +} + impl FromStr for RoundingMode { type Err = TemporalError; From 89b8b5bd04166d1fbe05e367329edfed54626956 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 14:56:40 -0600 Subject: [PATCH 02/11] Rework NudgeToCalendarUnit and initial pass at integer rounding --- src/builtins/core/duration/normalized.rs | 254 +++++++++++++++-------- src/builtins/core/duration/tests.rs | 88 ++++++++ src/options.rs | 58 ++++-- 3 files changed, 297 insertions(+), 103 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index d77c7b7a5..dd7e3c735 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -391,7 +391,6 @@ impl InternalDurationRecord { #[derive(Debug)] struct NudgeRecord { normalized: InternalDurationRecord, - total: Option, nudge_epoch_ns: i128, expanded: bool, } @@ -406,8 +405,90 @@ struct NudgeWindow { } impl InternalDurationRecord { - /// + /// `compute_nudge_window` in `temporal_rs` refers to step 1-12 of `NudgeToCalendarUnit`. + /// + /// For A.O. `ComputeNudgeWinodw`, see `compute_nudge_window_with_shift` fn compute_nudge_window( + &self, + sign: Sign, + origin_epoch_ns: EpochNanoseconds, + dest_epoch_ns: i128, + dt: &PlainDateTime, + time_zone: Option<(&TimeZone, &(impl TimeZoneProvider + ?Sized))>, // ??? + options: ResolvedRoundingOptions, + ) -> TemporalResult<(NudgeWindow, bool)> { + let dest_epoch_ns = EpochNanoseconds(dest_epoch_ns); + + // 1. Let didExpandCalendarUnit be false. + let mut did_expand_calendar_unit = false; + + // 2. Let nudgeWindow be ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, false). + let mut nudge_window = self.compute_nudge_window_with_shift( + sign, + origin_epoch_ns, + dt, + time_zone, + options, + false, + )?; + + // 3. Let startEpochNs be nudgeWindow.[[StartEpochNs]]. + // 4. Let endEpochNs be nudgeWindow.[[EndEpochNs]]. + // (implicitly used) + + // 5. If sign is 1, then + if sign != Sign::Negative { + // a. If startEpochNs ≤ destEpochNs ≤ endEpochNs is false, then + if !(nudge_window.start_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.end_epoch_ns) + { + // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). + nudge_window = self.compute_nudge_window_with_shift( + sign, + origin_epoch_ns, + dt, + time_zone, + options, + true, + )?; + // ii. Assert: nudgeWindow.[[StartEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[EndEpochNs]]. + temporal_assert!( + nudge_window.start_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.end_epoch_ns + ); + // iii. Set didExpandCalendarUnit to true. + did_expand_calendar_unit = true; + } + } else { + // a. If endEpochNs ≤ destEpochNs ≤ startEpochNs is false, then + if !(nudge_window.end_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.start_epoch_ns) + { + // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). + nudge_window = self.compute_nudge_window_with_shift( + sign, + origin_epoch_ns, + dt, + time_zone, + options, + true, + )?; + // ii. Assert: nudgeWindow.[[EndEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[StartEpochNs]]. + temporal_assert!( + nudge_window.end_epoch_ns <= dest_epoch_ns + && dest_epoch_ns <= nudge_window.start_epoch_ns + ); + // iii. Set didExpandCalendarUnit to true. + did_expand_calendar_unit = true; + } + } + + Ok((nudge_window, did_expand_calendar_unit)) + } + + // NOTE: this is the same as 7.5.33 `ComputeNudgeWindow` in + /// + fn compute_nudge_window_with_shift( &self, sign: Sign, origin_epoch_ns: EpochNanoseconds, @@ -471,28 +552,27 @@ impl InternalDurationRecord { let r1 = if !additional_shift { // i. Let r1 be months. months + // c. Else } else { // i. Let r1 be months + increment × sign. months + increment_x_sign }; - // c. Let r2 be r1 + increment × sign. + // d. Let r2 be r1 + increment × sign. let r2 = r1 + increment_x_sign; - // d. Let startDuration be ? CreateNormalizedDurationRecord(duration.[[Years]], r1, 0, 0, ZeroTimeDuration()). - // e. Let endDuration be ? CreateNormalizedDurationRecord(duration.[[Years]], r2, 0, 0, ZeroTimeDuration()). + // e. Let startDuration be ? AdjustDateDurationRecord(duration.[[Date]], 0, 0, r1). + // f. Let endDuration be ? AdjustDateDurationRecord(duration.[[Date]], 0, 0, r2). ( r1, r2, - DateDuration::new( - self.date().years, - i64::try_from(r1).map_err(|_| TemporalError::range())?, - 0, + self.date().adjust( 0, + None, + Some(i64::try_from(r1).map_err(|_| TemporalError::range())?), )?, - DateDuration::new( - self.date().years, - i64::try_from(r2).map_err(|_| TemporalError::range())?, - 0, + self.date().adjust( 0, + None, + Some(i64::try_from(r2).map_err(|_| TemporalError::range())?), )?, ) } @@ -686,9 +766,8 @@ impl InternalDurationRecord { end_duration, }) } - // TODO: Add assertion into impl. - // TODO: Add unit tests specifically for nudge_calendar_unit if possible. - fn nudge_calendar_unit( + + fn nudge_calendar_unit_total( &self, sign: Sign, origin_epoch_ns: EpochNanoseconds, @@ -696,55 +775,65 @@ impl InternalDurationRecord { dt: &PlainDateTime, time_zone: Option<(&TimeZone, &(impl TimeZoneProvider + ?Sized))>, // ??? options: ResolvedRoundingOptions, - ) -> TemporalResult { - let dest_epoch_ns = EpochNanoseconds(dest_epoch_ns); + ) -> TemporalResult { + let (nudge_window, _) = self.compute_nudge_window( + sign, + origin_epoch_ns, + dest_epoch_ns, + dt, + time_zone, + options, + )?; + // 7. Let r1 be nudgeWindow.[[R1]]. + // 8. Let r2 be nudgeWindow.[[R2]]. + // 9. Set startEpochNs to nudgeWindow.[[StartEpochNs]]. + // 10. Set endEpochNs to nudgeWindow.[[EndEpochNs]]. + // 11. Let startDuration be nudgeWindow.[[StartDuration]]. + // 12. Let endDuration be nudgeWindow.[[EndDuration]]. - // 1. Let didExpandCalendarUnit be false. - let mut did_expand_calendar_unit = false; + let NudgeWindow { + r1, + start_epoch_ns, + end_epoch_ns, + .. + } = nudge_window; - // 2. Let nudgeWindow be ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, false). - let mut nudge_window = - self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, false)?; + // 13. Assert: startEpochNs ≠ endEpochNs. + temporal_assert!(start_epoch_ns != end_epoch_ns); - // 3. Let startEpochNs be nudgeWindow.[[StartEpochNs]]. - // 4. Let endEpochNs be nudgeWindow.[[EndEpochNs]]. - // (implicitly used) + // TODO: Don't use f64 below ... + // NOTE(nekevss): Step 12..13 could be problematic...need tests + // and verify, or completely change the approach involved. + // TODO(nekevss): Validate that the `f64` casts here are valid in all scenarios + // 14. Let progress be (destEpochNs - startEpochNs) / (endEpochNs - startEpochNs). + // 15. Let total be r1 + progress × increment × sign. + let progress = + (dest_epoch_ns - start_epoch_ns.0) as f64 / (end_epoch_ns.0 - start_epoch_ns.0) as f64; + let total = r1 as f64 + + progress * options.increment.get() as f64 * f64::from(sign.as_sign_multiplier()); - // 5. If sign is 1, then - if sign != Sign::Negative { - // a. If startEpochNs ≤ destEpochNs ≤ endEpochNs is false, then - if !(nudge_window.start_epoch_ns <= dest_epoch_ns - && dest_epoch_ns <= nudge_window.end_epoch_ns) - { - // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). - nudge_window = - self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, true)?; - // ii. Assert: nudgeWindow.[[StartEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[EndEpochNs]]. - temporal_assert!( - nudge_window.start_epoch_ns <= dest_epoch_ns - && dest_epoch_ns <= nudge_window.end_epoch_ns - ); - // iii. Set didExpandCalendarUnit to true. - did_expand_calendar_unit = true; - } - } else { - // a. If endEpochNs ≤ destEpochNs ≤ startEpochNs is false, then - if !(nudge_window.end_epoch_ns <= dest_epoch_ns - && dest_epoch_ns <= nudge_window.start_epoch_ns) - { - // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). - nudge_window = - self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, true)?; - // ii. Assert: nudgeWindow.[[EndEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[StartEpochNs]]. - temporal_assert!( - nudge_window.end_epoch_ns <= dest_epoch_ns - && dest_epoch_ns <= nudge_window.start_epoch_ns - ); - // iii. Set didExpandCalendarUnit to true. - did_expand_calendar_unit = true; - } - } + FiniteF64::try_from(total) + } + // TODO: Add assertion into impl. + // TODO: Add unit tests specifically for nudge_calendar_unit if possible. + fn nudge_calendar_unit( + &self, + sign: Sign, + origin_epoch_ns: EpochNanoseconds, + dest_epoch_ns: i128, + dt: &PlainDateTime, + time_zone: Option<(&TimeZone, &(impl TimeZoneProvider + ?Sized))>, // ??? + options: ResolvedRoundingOptions, + ) -> TemporalResult { + let (nudge_window, did_expand_calendar_unit) = self.compute_nudge_window( + sign, + origin_epoch_ns, + dest_epoch_ns, + dt, + time_zone, + options, + )?; // 7. Let r1 be nudgeWindow.[[R1]]. // 8. Let r2 be nudgeWindow.[[R2]]. // 9. Set startEpochNs to nudgeWindow.[[StartEpochNs]]. @@ -764,22 +853,27 @@ impl InternalDurationRecord { // 13. Assert: startEpochNs ≠ endEpochNs. temporal_assert!(start_epoch_ns != end_epoch_ns); - // TODO: Don't use f64 below ... - // NOTE(nekevss): Step 12..13 could be problematic...need tests + // NOTE(nekevss): Step 14..15 could be problematic...need tests // and verify, or completely change the approach involved. - // TODO(nekevss): Validate that the `f64` casts here are valid in all scenarios + // + // We change our calculations to remove any f64 usage. // 14. Let progress be (destEpochNs - startEpochNs) / (endEpochNs - startEpochNs). // 15. Let total be r1 + progress × increment × sign. - let progress = (dest_epoch_ns.0 - start_epoch_ns.0) as f64 - / (end_epoch_ns.0 - start_epoch_ns.0) as f64; - let total = r1 as f64 - + progress * options.increment.get() as f64 * f64::from(sign.as_sign_multiplier()); + let dividend = dest_epoch_ns - start_epoch_ns.0; + let divisor = end_epoch_ns.0 - start_epoch_ns.0; + + let progress_dividend = + dividend * options.increment.get() as i128 * (sign.as_sign_multiplier() as i128); + + let progress = progress_dividend.rem_euclid(divisor) / divisor; + + // We add r1 to the dividend + let total_dividend = dividend + (r1 * divisor); // 16. NOTE: The above two steps cannot be implemented directly using floating-point arithmetic. // This division can be implemented as if constructing Normalized Time Duration Records for the denominator // and numerator of total and performing one division operation with a floating-point result. // 17. Assert: 0 ≤ progress ≤ 1. - temporal_assert!((0. ..=1.).contains(&progress)); // 18. If sign < 0, let isNegative be negative; else let isNegative be positive. // (used implicitly) @@ -790,15 +884,19 @@ impl InternalDurationRecord { .get_unsigned_round_mode(sign != Sign::Negative); // 20. If progress = 1, then - let rounded_unit = if progress == 1. { + let rounded_unit = if progress == 1 { // a. Let roundedUnit be abs(r2). r2.abs() } else { // a. Assert: abs(r1) ≤ abs(total) < abs(r2). - temporal_assert!(r1.abs() as f64 <= total.abs() && total.abs() < r2.abs() as f64); // b. Let roundedUnit be ApplyUnsignedRoundingMode(abs(total), abs(r1), abs(r2), unsignedRoundingMode). // TODO: what happens to r2 here? - unsigned_rounding_mode.apply(total.abs(), r1.abs(), r2.abs()) + unsigned_rounding_mode.apply( + total_dividend.unsigned_abs(), + divisor.unsigned_abs(), + r1.abs(), + r2.abs(), + ) }; // 22. If roundedUnit is abs(r2), then @@ -808,7 +906,6 @@ impl InternalDurationRecord { // c. Let nudgedEpochNs be endEpochNs. Ok(NudgeRecord { normalized: InternalDurationRecord::new(end_duration, TimeDuration::default())?, - total: Some(FiniteF64::try_from(total)?), nudge_epoch_ns: end_epoch_ns.0, expanded: true, }) @@ -818,14 +915,13 @@ impl InternalDurationRecord { // c. Let nudgedEpochNs be startEpochNs. Ok(NudgeRecord { normalized: InternalDurationRecord::new(start_duration, TimeDuration::default())?, - total: Some(FiniteF64::try_from(total)?), nudge_epoch_ns: start_epoch_ns.0, expanded: did_expand_calendar_unit, }) } } - // TODO: Clean up + // Round a duration to a time unit based on a relative `ZonedDateTime` #[inline] fn nudge_to_zoned_time( &self, @@ -912,7 +1008,6 @@ impl InternalDurationRecord { Ok(NudgeRecord { normalized, nudge_epoch_ns: nudge_ns.0, - total: None, expanded, }) } @@ -977,7 +1072,6 @@ impl InternalDurationRecord { // 16. Return Duration Nudge Result Record { [[Duration]]: resultDuration, [[NudgedEpochNs]]: nudgedEpochNs, [[DidExpandCalendarUnit]]: didExpandDays }. Ok(NudgeRecord { normalized: result_duration, - total: None, nudge_epoch_ns: nudged_ns, expanded: did_expand_days, }) @@ -1204,7 +1298,8 @@ impl InternalDurationRecord { // a. Let sign be InternalDurationSign(duration). let sign = self.sign(); // b. Let record be ? NudgeToCalendarUnit(sign, duration, destEpochNs, isoDateTime, timeZone, calendar, 1, unit, trunc). - let record = self.nudge_calendar_unit( + // c. Return record.[[Total]]. + return self.nudge_calendar_unit_total( sign, origin_epoch_ns, dest_epoch_ns, @@ -1216,10 +1311,7 @@ impl InternalDurationRecord { increment: RoundingIncrement::default(), rounding_mode: RoundingMode::Trunc, }, - )?; - - // c. Return record.[[Total]]. - return record.total.temporal_unwrap(); + ); } // 2. Let timeDuration be ! Add24HourDaysToTimeDuration(duration.[[Time]], duration.[[Date]].[[Days]]). let time_duration = self diff --git a/src/builtins/core/duration/tests.rs b/src/builtins/core/duration/tests.rs index 5b68fe9f2..d353b87be 100644 --- a/src/builtins/core/duration/tests.rs +++ b/src/builtins/core/duration/tests.rs @@ -5,6 +5,7 @@ use crate::{ parsers::Precision, partial::PartialDuration, provider::NeverProvider, + ZonedDateTime, }; use super::Duration; @@ -475,3 +476,90 @@ fn test_nudge_relative_date_total() { 1.0134408602150538 ); } + +// Adapted from roundingincrement-addition-out-of-range.js +#[test] +#[cfg(feature = "compiled_data")] +fn rounding_out_of_range() { + use crate::options::{DifferenceSettings, RoundingMode}; + use crate::TimeZone; + let earlier = ZonedDateTime::try_new_iso(0, TimeZone::utc()).unwrap(); + let later = ZonedDateTime::try_new_iso(5, TimeZone::utc()).unwrap(); + + let options = DifferenceSettings { + smallest_unit: Some(Unit::Day), + increment: Some(RoundingIncrement::try_new(100_000_001).unwrap()), + ..Default::default() + }; + let error = later.since(&earlier, options); + assert!( + error.is_err(), + "Ending bound 100_000_001 is out of range and should fail." + ); + + let error = earlier.since(&later, options); + assert!( + error.is_err(), + "Ending bound -100_000_001 is out of range and should fail." + ); + + let options = DifferenceSettings { + smallest_unit: Some(Unit::Day), + increment: Some(RoundingIncrement::try_new(100_000_000).unwrap()), + rounding_mode: Some(RoundingMode::Expand), + ..Default::default() + }; + let duration = later.since(&earlier, options).unwrap(); + assert_eq!(duration.days(), 100_000_000); + + let duration = earlier.since(&later, options).unwrap(); + assert_eq!(duration.days(), -100_000_000); +} + +#[test] +#[cfg(feature = "compiled_data")] +fn rounding_window() { + use crate::PlainDate; + + fn duration(years: i64, months: i64, weeks: i64, days: i64, hours: i64) -> Duration { + Duration::new(years, months, weeks, days, hours, 0, 0, 0, 0, 0).unwrap() + } + + let d = duration(1, 0, 0, 0, 1); + let relative_to = PlainDate::try_new_iso(2020, 2, 29).unwrap(); + let options = RoundingOptions { + smallest_unit: Some(Unit::Year), + ..Default::default() + }; + let result = d.round(options, Some(relative_to.into())).unwrap(); + assert_eq!(result.years(), 1, "years must round down to 1"); + + let d = duration(0, 1, 0, 0, 10); + let relative_to = PlainDate::try_new_iso(2020, 1, 31).unwrap(); + let options = RoundingOptions { + smallest_unit: Some(Unit::Month), + rounding_mode: Some(crate::options::RoundingMode::Expand), + ..Default::default() + }; + let result = d.round(options, Some(relative_to.into())).unwrap(); + assert_eq!(result.months(), 2, "months rounding should expand to 2"); + + let d = duration(2345, 0, 0, 0, 12); + let relative_to = PlainDate::try_new_iso(2020, 2, 29).unwrap(); + let options = RoundingOptions { + smallest_unit: Some(Unit::Year), + rounding_mode: Some(crate::options::RoundingMode::Expand), + ..Default::default() + }; + let result = d.round(options, Some(relative_to.into())).unwrap(); + assert_eq!(result.years(), 2346, "years rounding should expand to 2346"); + + let d = duration(1, 0, 0, 0, 0); + let relative_to = PlainDate::try_new_iso(2020, 2, 29).unwrap(); + let options = RoundingOptions { + smallest_unit: Some(Unit::Month), + ..Default::default() + }; + let result = d.round(options, Some(relative_to.into())).unwrap(); + assert_eq!(result.years(), 1, "months rounding should no-op"); +} diff --git a/src/options.rs b/src/options.rs index 1f8a9ec69..11a627e53 100644 --- a/src/options.rs +++ b/src/options.rs @@ -6,6 +6,7 @@ use crate::parsers::Precision; use crate::TemporalUnwrap; use crate::{error::ErrorMessage, TemporalError, TemporalResult, MS_PER_DAY, NS_PER_DAY}; +use core::cmp::Ordering; use core::num::NonZeroU128; use core::ops::Add; use core::{fmt, str::FromStr}; @@ -849,9 +850,9 @@ impl RoundingMode { impl UnsignedRoundingMode { /// - pub(crate) fn apply(self, x: f64, r1: i128, r2: i128) -> i128 { + pub(crate) fn apply(self, dividend: u128, divisor: u128, r1: i128, r2: i128) -> i128 { // 1. If x = r1, return r1. - if r1 as f64 == x { + if is_exact(dividend, divisor) { return r1; } // 4. If unsignedRoundingMode is zero, return r1. @@ -862,32 +863,45 @@ impl UnsignedRoundingMode { } // 6. Let d1 be x – r1. // 7. Let d2 be r2 – x. - let d1 = x - r1 as f64; - let d2 = r2 as f64 - x; - if d1 < d2 { - return r1; - } else if d1 > d2 { - return r2; - } - match self { - UnsignedRoundingMode::HalfZero => r1, - UnsignedRoundingMode::HalfInfinity => r2, - // HalfEven - _ => { - // 14. Let cardinality be (r1 / (r2 – r1)) modulo 2. - let diff = r2 - r1; - let cardinality = (r1 as f64 / diff as f64) % 2.; - // 15. If cardinality = 0, return r1. - if cardinality == 0. { - r1 - } else { - r2 + let midway = (r2.unsigned_abs() * divisor - r1.unsigned_abs() * divisor).div_euclid(2); + match compare_remainder(dividend, divisor, midway) { + Ordering::Less => r1, + Ordering::Greater => r2, + Ordering::Equal => { + match self { + UnsignedRoundingMode::HalfZero => r1, + UnsignedRoundingMode::HalfInfinity => r2, + // HalfEven + _ => { + // 14. Let cardinality be (r1 / (r2 – r1)) modulo 2. + let diff = r2 - r1; + let cardinality = r1.div_euclid(diff).rem_euclid(2); + // 15. If cardinality = 0, return r1. + if cardinality == 0 { + r1 + } else { + r2 + } + } } } } } } +fn is_exact(dividend: u128, divisor: u128) -> bool { + dividend.rem_euclid(divisor) == 0 +} + +fn compare_remainder(dividend: u128, divisor: u128, midway: u128) -> Ordering { + let cmp = dividend.rem_euclid(divisor).cmp(&midway); + if cmp == Ordering::Equal && divisor.rem_euclid(2) != 0 { + Ordering::Less + } else { + cmp + } +} + impl FromStr for RoundingMode { type Err = TemporalError; From b4ecc18324eb6a8581dfc748f3d8d15bd074c67b Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 21:31:34 -0600 Subject: [PATCH 03/11] Fix rounding so that it works --- src/builtins/core/duration/normalized.rs | 14 +++++++------- src/builtins/core/duration/tests.rs | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index dd7e3c735..97f6da4fe 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -862,13 +862,11 @@ impl InternalDurationRecord { let dividend = dest_epoch_ns - start_epoch_ns.0; let divisor = end_epoch_ns.0 - start_epoch_ns.0; - let progress_dividend = - dividend * options.increment.get() as i128 * (sign.as_sign_multiplier() as i128); - - let progress = progress_dividend.rem_euclid(divisor) / divisor; - // We add r1 to the dividend - let total_dividend = dividend + (r1 * divisor); + let total_dividend = dividend + + (r1 * divisor) + * options.increment.get() as i128 + * i128::from(sign.as_sign_multiplier()); // 16. NOTE: The above two steps cannot be implemented directly using floating-point arithmetic. // This division can be implemented as if constructing Normalized Time Duration Records for the denominator @@ -884,7 +882,9 @@ impl InternalDurationRecord { .get_unsigned_round_mode(sign != Sign::Negative); // 20. If progress = 1, then - let rounded_unit = if progress == 1 { + let total_is_r2 = + total_dividend.div_euclid(divisor) == r2 && total_dividend.rem_euclid(divisor) == 0; + let rounded_unit = if total_is_r2 { // a. Let roundedUnit be abs(r2). r2.abs() } else { diff --git a/src/builtins/core/duration/tests.rs b/src/builtins/core/duration/tests.rs index d353b87be..a4b178f4a 100644 --- a/src/builtins/core/duration/tests.rs +++ b/src/builtins/core/duration/tests.rs @@ -486,7 +486,7 @@ fn rounding_out_of_range() { let earlier = ZonedDateTime::try_new_iso(0, TimeZone::utc()).unwrap(); let later = ZonedDateTime::try_new_iso(5, TimeZone::utc()).unwrap(); - let options = DifferenceSettings { + let options = DifferenceSettings { smallest_unit: Some(Unit::Day), increment: Some(RoundingIncrement::try_new(100_000_001).unwrap()), ..Default::default() @@ -503,7 +503,7 @@ fn rounding_out_of_range() { "Ending bound -100_000_001 is out of range and should fail." ); - let options = DifferenceSettings { + let options = DifferenceSettings { smallest_unit: Some(Unit::Day), increment: Some(RoundingIncrement::try_new(100_000_000).unwrap()), rounding_mode: Some(RoundingMode::Expand), @@ -516,6 +516,21 @@ fn rounding_out_of_range() { assert_eq!(duration.days(), -100_000_000); } +/* TODO: Make adjustments so this passes. +#[test] +#[cfg(feature = "compiled_data")] +fn total_precision() { + use crate::PlainDate; + + let d = Duration::new(0, 0, 5, 5, 0, 0, 0, 0, 0, 0).unwrap(); + + let relative_to = PlainDate::try_new_iso(1972, 1, 31).unwrap(); + let result = d.total(Unit::Month, Some(relative_to.into())).unwrap(); + + assert_eq!(result.0, 1.3548387096774193, "Loss of precision on Duration::total"); +} +*/ + #[test] #[cfg(feature = "compiled_data")] fn rounding_window() { From 1baa19e690b80eb02cf37a8d4948ee599e4a94e3 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 21:45:12 -0600 Subject: [PATCH 04/11] Add some notes for the rounding changes --- src/builtins/core/duration/normalized.rs | 37 ++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index 97f6da4fe..edaaeacad 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -853,10 +853,35 @@ impl InternalDurationRecord { // 13. Assert: startEpochNs ≠ endEpochNs. temporal_assert!(start_epoch_ns != end_epoch_ns); - // NOTE(nekevss): Step 14..15 could be problematic...need tests - // and verify, or completely change the approach involved. + // NOTE (nekevss): // // We change our calculations to remove any f64 usage. + // + // So let's go over what we do to handle this ... well, basically, + // just math. + // + // We take `r1 + progress * increment * sign` and plug in the progress calculation + // + // So, in other words, stepping through the calculations + // + // NOTE: For shorthand, + // + // dividend = (destEpochNS - startEpochNS) + // divisor = (endEpochNS - startEpochNS) + // + // progress = dividend / divisor + // + // 1. r1 + progress * increment * sign + // 2. r1 + (dividend / divisor) * increment * sign + // + // Bring in increment and sign + // + // 3. r1 + (dividend * increment * sign) / divisor + // + // Now also move the r1 into the progress fraction. + // + // 4. ((r1 * divisor) + dividend * increment * sign) / divisor + // // 14. Let progress be (destEpochNs - startEpochNs) / (endEpochNs - startEpochNs). // 15. Let total be r1 + progress × increment × sign. let dividend = dest_epoch_ns - start_epoch_ns.0; @@ -881,6 +906,14 @@ impl InternalDurationRecord { .rounding_mode .get_unsigned_round_mode(sign != Sign::Negative); + // NOTE (nekevss): + // + // Now we need to eliminate whether our "total" would be the value of r2 + // exactly, AKA `progress = 1`. + // + // To do this, we check if the quotient of dividend and divisor is r2 and + // that there is no remainder caused by the calculation. + // // 20. If progress = 1, then let total_is_r2 = total_dividend.div_euclid(divisor) == r2 && total_dividend.rem_euclid(divisor) == 0; From 490f3e9b9500af0436b1d448ebdd2a48150a3a55 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 22:23:53 -0600 Subject: [PATCH 05/11] Rename abstract operations --- src/builtins/core/duration/normalized.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index edaaeacad..4d6ec823b 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -408,7 +408,7 @@ impl InternalDurationRecord { /// `compute_nudge_window` in `temporal_rs` refers to step 1-12 of `NudgeToCalendarUnit`. /// /// For A.O. `ComputeNudgeWinodw`, see `compute_nudge_window_with_shift` - fn compute_nudge_window( + fn compute_and_adjust_nudge_window( &self, sign: Sign, origin_epoch_ns: EpochNanoseconds, @@ -423,7 +423,7 @@ impl InternalDurationRecord { let mut did_expand_calendar_unit = false; // 2. Let nudgeWindow be ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, false). - let mut nudge_window = self.compute_nudge_window_with_shift( + let mut nudge_window = self.compute_nudge_window( sign, origin_epoch_ns, dt, @@ -443,7 +443,7 @@ impl InternalDurationRecord { && dest_epoch_ns <= nudge_window.end_epoch_ns) { // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). - nudge_window = self.compute_nudge_window_with_shift( + nudge_window = self.compute_nudge_window( sign, origin_epoch_ns, dt, @@ -465,7 +465,7 @@ impl InternalDurationRecord { && dest_epoch_ns <= nudge_window.start_epoch_ns) { // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). - nudge_window = self.compute_nudge_window_with_shift( + nudge_window = self.compute_nudge_window( sign, origin_epoch_ns, dt, @@ -486,9 +486,8 @@ impl InternalDurationRecord { Ok((nudge_window, did_expand_calendar_unit)) } - // NOTE: this is the same as 7.5.33 `ComputeNudgeWindow` in /// - fn compute_nudge_window_with_shift( + fn compute_nudge_window( &self, sign: Sign, origin_epoch_ns: EpochNanoseconds, @@ -776,7 +775,7 @@ impl InternalDurationRecord { time_zone: Option<(&TimeZone, &(impl TimeZoneProvider + ?Sized))>, // ??? options: ResolvedRoundingOptions, ) -> TemporalResult { - let (nudge_window, _) = self.compute_nudge_window( + let (nudge_window, _) = self.compute_and_adjust_nudge_window( sign, origin_epoch_ns, dest_epoch_ns, @@ -826,7 +825,7 @@ impl InternalDurationRecord { time_zone: Option<(&TimeZone, &(impl TimeZoneProvider + ?Sized))>, // ??? options: ResolvedRoundingOptions, ) -> TemporalResult { - let (nudge_window, did_expand_calendar_unit) = self.compute_nudge_window( + let (nudge_window, did_expand_calendar_unit) = self.compute_and_adjust_nudge_window( sign, origin_epoch_ns, dest_epoch_ns, From 212f1410237f878269d692c4061b82e231d14b4c Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 22:24:36 -0600 Subject: [PATCH 06/11] Fix bug introduced by midway calculation --- src/builtins/core/plain_date.rs | 45 +++++++++++++++++++++++++++++++++ src/options.rs | 6 ++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/builtins/core/plain_date.rs b/src/builtins/core/plain_date.rs index 31ba19eb5..19232e624 100644 --- a/src/builtins/core/plain_date.rs +++ b/src/builtins/core/plain_date.rs @@ -706,6 +706,8 @@ impl FromStr for PlainDate { mod tests { use tinystr::tinystr; + use crate::options::{RoundingIncrement, RoundingMode}; + use super::*; #[test] @@ -1041,4 +1043,47 @@ mod tests { assert!(PlainDate::from_str(s).is_err()) } } + + #[test] + fn rounding_increment_observed() { + let earlier = PlainDate::try_new_iso(2019, 1, 8).unwrap(); + let later = PlainDate::try_new_iso(2021, 9, 7).unwrap(); + + let settings = DifferenceSettings { + smallest_unit: Some(Unit::Year), + rounding_mode: Some(RoundingMode::HalfExpand), + increment: Some(RoundingIncrement::try_new(4).unwrap()), + ..Default::default() + }; + let result = later.since(&earlier, settings).unwrap(); + assert_eq!(result.years(), 4); + + let settings = DifferenceSettings { + smallest_unit: Some(Unit::Month), + rounding_mode: Some(RoundingMode::HalfExpand), + increment: Some(RoundingIncrement::try_new(10).unwrap()), + ..Default::default() + }; + let result = later.since(&earlier, settings).unwrap(); + assert_eq!(result.months(), 30); + + let settings = DifferenceSettings { + smallest_unit: Some(Unit::Week), + rounding_mode: Some(RoundingMode::HalfExpand), + increment: Some(RoundingIncrement::try_new(12).unwrap()), + ..Default::default() + }; + let result = later.since(&earlier, settings).unwrap(); + assert_eq!(result.weeks(), 144); + + + let settings = DifferenceSettings { + smallest_unit: Some(Unit::Day), + rounding_mode: Some(RoundingMode::HalfExpand), + increment: Some(RoundingIncrement::try_new(100).unwrap()), + ..Default::default() + }; + let result = later.since(&earlier, settings).unwrap(); + assert_eq!(result.days(), 1000); + } } diff --git a/src/options.rs b/src/options.rs index 11a627e53..8461a8785 100644 --- a/src/options.rs +++ b/src/options.rs @@ -863,8 +863,7 @@ impl UnsignedRoundingMode { } // 6. Let d1 be x – r1. // 7. Let d2 be r2 – x. - let midway = (r2.unsigned_abs() * divisor - r1.unsigned_abs() * divisor).div_euclid(2); - match compare_remainder(dividend, divisor, midway) { + match compare_remainder(dividend, divisor) { Ordering::Less => r1, Ordering::Greater => r2, Ordering::Equal => { @@ -893,7 +892,8 @@ fn is_exact(dividend: u128, divisor: u128) -> bool { dividend.rem_euclid(divisor) == 0 } -fn compare_remainder(dividend: u128, divisor: u128, midway: u128) -> Ordering { +fn compare_remainder(dividend: u128, divisor: u128) -> Ordering { + let midway = divisor.div_euclid(2); let cmp = dividend.rem_euclid(divisor).cmp(&midway); if cmp == Ordering::Equal && divisor.rem_euclid(2) != 0 { Ordering::Less From 7793644cdfff5851c1dd5f5de44918ec456b244d Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 22:25:19 -0600 Subject: [PATCH 07/11] cargo fmt --- src/builtins/core/duration/normalized.rs | 30 +++++------------------- src/builtins/core/plain_date.rs | 1 - 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index 4d6ec823b..a216609b5 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -423,14 +423,8 @@ impl InternalDurationRecord { let mut did_expand_calendar_unit = false; // 2. Let nudgeWindow be ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, false). - let mut nudge_window = self.compute_nudge_window( - sign, - origin_epoch_ns, - dt, - time_zone, - options, - false, - )?; + let mut nudge_window = + self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, false)?; // 3. Let startEpochNs be nudgeWindow.[[StartEpochNs]]. // 4. Let endEpochNs be nudgeWindow.[[EndEpochNs]]. @@ -443,14 +437,8 @@ impl InternalDurationRecord { && dest_epoch_ns <= nudge_window.end_epoch_ns) { // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). - nudge_window = self.compute_nudge_window( - sign, - origin_epoch_ns, - dt, - time_zone, - options, - true, - )?; + nudge_window = + self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, true)?; // ii. Assert: nudgeWindow.[[StartEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[EndEpochNs]]. temporal_assert!( nudge_window.start_epoch_ns <= dest_epoch_ns @@ -465,14 +453,8 @@ impl InternalDurationRecord { && dest_epoch_ns <= nudge_window.start_epoch_ns) { // i. Set nudgeWindow to ? ComputeNudgeWindow(sign, duration, originEpochNs, isoDateTime, timeZone, calendar, increment, unit, true). - nudge_window = self.compute_nudge_window( - sign, - origin_epoch_ns, - dt, - time_zone, - options, - true, - )?; + nudge_window = + self.compute_nudge_window(sign, origin_epoch_ns, dt, time_zone, options, true)?; // ii. Assert: nudgeWindow.[[EndEpochNs]] ≤ destEpochNs ≤ nudgeWindow.[[StartEpochNs]]. temporal_assert!( nudge_window.end_epoch_ns <= dest_epoch_ns diff --git a/src/builtins/core/plain_date.rs b/src/builtins/core/plain_date.rs index 19232e624..fb9fbfb24 100644 --- a/src/builtins/core/plain_date.rs +++ b/src/builtins/core/plain_date.rs @@ -1076,7 +1076,6 @@ mod tests { let result = later.since(&earlier, settings).unwrap(); assert_eq!(result.weeks(), 144); - let settings = DifferenceSettings { smallest_unit: Some(Unit::Day), rounding_mode: Some(RoundingMode::HalfExpand), From f31f4d7a16e35a9538b7d2d760c09bde2f5c3812 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 22:28:14 -0600 Subject: [PATCH 08/11] Cleanup comment on AO --- src/builtins/core/duration/normalized.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index a216609b5..ffc3ff55a 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -405,9 +405,7 @@ struct NudgeWindow { } impl InternalDurationRecord { - /// `compute_nudge_window` in `temporal_rs` refers to step 1-12 of `NudgeToCalendarUnit`. - /// - /// For A.O. `ComputeNudgeWinodw`, see `compute_nudge_window_with_shift` + /// `compute_and_adjust_nudge_window` in `temporal_rs` refers to step 1-12 of `NudgeToCalendarUnit`. fn compute_and_adjust_nudge_window( &self, sign: Sign, From 3cedf145cc79e216d1a52cac9b3a314811f1ce81 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Wed, 10 Dec 2025 22:32:15 -0600 Subject: [PATCH 09/11] Fix broken import --- src/builtins/core/duration/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/builtins/core/duration/tests.rs b/src/builtins/core/duration/tests.rs index a4b178f4a..e90d0827f 100644 --- a/src/builtins/core/duration/tests.rs +++ b/src/builtins/core/duration/tests.rs @@ -5,7 +5,6 @@ use crate::{ parsers::Precision, partial::PartialDuration, provider::NeverProvider, - ZonedDateTime, }; use super::Duration; @@ -482,7 +481,7 @@ fn test_nudge_relative_date_total() { #[cfg(feature = "compiled_data")] fn rounding_out_of_range() { use crate::options::{DifferenceSettings, RoundingMode}; - use crate::TimeZone; + use crate::{TimeZone, ZonedDateTime}; let earlier = ZonedDateTime::try_new_iso(0, TimeZone::utc()).unwrap(); let later = ZonedDateTime::try_new_iso(5, TimeZone::utc()).unwrap(); From f1c28287a0b62425aac8500460c6120356073cfc Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Thu, 11 Dec 2025 10:58:03 -0600 Subject: [PATCH 10/11] Fix precision bug --- src/builtins/core/duration/normalized.rs | 47 +++++++++++++++++++----- src/builtins/core/duration/tests.rs | 7 ++-- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index ffc3ff55a..a219c5058 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -395,6 +395,7 @@ struct NudgeRecord { expanded: bool, } +#[derive(Debug)] struct NudgeWindow { r1: i128, r2: i128, @@ -780,18 +781,46 @@ impl InternalDurationRecord { // 13. Assert: startEpochNs ≠ endEpochNs. temporal_assert!(start_epoch_ns != end_epoch_ns); - // TODO: Don't use f64 below ... - // NOTE(nekevss): Step 12..13 could be problematic...need tests - // and verify, or completely change the approach involved. - // TODO(nekevss): Validate that the `f64` casts here are valid in all scenarios + // NOTE (nekevss): re: nudge_calendar_unit + // + // We change our calculations here to limit f64 usage, but also to preserve + // precision on the calculation. + // + // So let's go over what we do to handle this ... well, basically, + // just math. + // + // We take `r1 + progress * increment * sign` and plug in the progress calculation + // + // So, in other words, stepping through the calculations + // + // NOTE: For shorthand, + // + // dividend = (destEpochNS - startEpochNS) + // divisor = (endEpochNS - startEpochNS) + // + // progress = dividend / divisor + // + // 1. r1 + progress * increment * sign + // 2. r1 + (dividend / divisor) * increment * sign + // + // Bring in increment and sign + // + // 3. r1 + (dividend * increment * sign) / divisor + // + // Now also move the r1 into the progress fraction. + // + // 4. ((r1 * divisor) + dividend * increment * sign) / divisor + // // 14. Let progress be (destEpochNs - startEpochNs) / (endEpochNs - startEpochNs). // 15. Let total be r1 + progress × increment × sign. - let progress = - (dest_epoch_ns - start_epoch_ns.0) as f64 / (end_epoch_ns.0 - start_epoch_ns.0) as f64; - let total = r1 as f64 - + progress * options.increment.get() as f64 * f64::from(sign.as_sign_multiplier()); + let progress_numerator = dest_epoch_ns - start_epoch_ns.0; + let denominator = end_epoch_ns.0 - start_epoch_ns.0; + let total_numerator = (r1 * denominator) + + progress_numerator + * options.increment.get() as i128 + * i128::from(sign.as_sign_multiplier()); - FiniteF64::try_from(total) + FiniteF64::try_from(total_numerator as f64 / denominator as f64) } // TODO: Add assertion into impl. diff --git a/src/builtins/core/duration/tests.rs b/src/builtins/core/duration/tests.rs index e90d0827f..51c88abb7 100644 --- a/src/builtins/core/duration/tests.rs +++ b/src/builtins/core/duration/tests.rs @@ -515,7 +515,6 @@ fn rounding_out_of_range() { assert_eq!(duration.days(), -100_000_000); } -/* TODO: Make adjustments so this passes. #[test] #[cfg(feature = "compiled_data")] fn total_precision() { @@ -526,9 +525,11 @@ fn total_precision() { let relative_to = PlainDate::try_new_iso(1972, 1, 31).unwrap(); let result = d.total(Unit::Month, Some(relative_to.into())).unwrap(); - assert_eq!(result.0, 1.3548387096774193, "Loss of precision on Duration::total"); + assert_eq!( + result.0, 1.3548387096774193, + "Loss of precision on Duration::total" + ); } -*/ #[test] #[cfg(feature = "compiled_data")] From b0555f3a0d1df3a6df4c69ff19c42cd11f6178a2 Mon Sep 17 00:00:00 2001 From: Kevin Ness Date: Thu, 11 Dec 2025 21:31:36 -0600 Subject: [PATCH 11/11] Use fraction for total calculation --- src/builtins/core/duration/normalized.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index 15c2f4334..3264fe5ce 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -820,7 +820,7 @@ impl InternalDurationRecord { * options.increment.get() as i128 * i128::from(sign.as_sign_multiplier()); - FiniteF64::try_from(total_numerator as f64 / denominator as f64) + Ok(Fraction::new(total_numerator, denominator as f64).to_finite_f64()) } // TODO: Add assertion into impl.