Skip to content
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

Normative: Fix Duration rounding relative to ZonedDateTime #2758

Merged
merged 4 commits into from May 14, 2024

Conversation

ptomato
Copy link
Collaborator

@ptomato ptomato commented Jan 23, 2024

When the time portion of a duration, rounded relative to a ZonedDateTime, would land on a non-24-hour day, we'd get incorrect results. This is a regression from #2508 where although switching the order of BalanceDateDurationRelative and BalanceTimeDurationRelative was correct, we should not have removed the MoveRelativeZonedDateTime call.

Closes: #2742

Copy link

codecov bot commented Jan 23, 2024

Codecov Report

Attention: Patch coverage is 96.86235% with 31 lines in your changes are missing coverage. Please review.

Project coverage is 96.49%. Comparing base (985b826) to head (c4c071a).
Report is 47 commits behind head on main.

❗ Current head c4c071a differs from pull request most recent head 55439c6. Consider uploading reports for the commit 55439c6 to get more accurate results

Files Patch % Lines
polyfill/lib/ecmascript.mjs 97.04% 25 Missing ⚠️
polyfill/lib/duration.mjs 96.45% 4 Missing and 1 partial ⚠️
polyfill/lib/timeduration.mjs 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2758      +/-   ##
==========================================
- Coverage   96.51%   96.49%   -0.02%     
==========================================
  Files          23       23              
  Lines       12386    12501     +115     
  Branches     2294     2257      -37     
==========================================
+ Hits        11954    12063     +109     
- Misses        372      380       +8     
+ Partials       60       58       -2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@arshaw
Copy link
Contributor

arshaw commented Jan 23, 2024

Thanks @ptomato, this successfully addresses my concerns brought up in #2742 (comment)

I've verified it succeeds under my original test case...

const past = Temporal.ZonedDateTime.from("2019-11-01T00:00:00-07:00[America/Vancouver]")
const duration = Temporal.Duration.from({ years: 1, hours: 24 })
const future = past.add(duration) // 2020-11-01T23:00:00-08:00[America/Vancouver]
duration.round({ largestUnit: 'years', relativeTo: past }).toString() // P1YT24H -- CORRECT

...as well as succeeding in some others...

const past = Temporal.ZonedDateTime.from("2019-11-01T00:00:00-07:00[America/Vancouver]")
const duration = Temporal.Duration.from({ days: 366, hours: 24 })
const future = past.add(duration) // 2020-11-01T23:00:00-08:00[America/Vancouver]
duration.round({ largestUnit: 'years', relativeTo: past }).toString() // P1YT24H -- CORRECT
const past = Temporal.ZonedDateTime.from("2020-10-31T00:00:00-07:00[America/Vancouver]")
const duration = Temporal.Duration.from({ days: 1, hours: 24 })
const future = past.add(duration) // 2020-11-01T23:00:00-08:00[America/Vancouver]
duration.round({ largestUnit: 'years', relativeTo: past }).toString() // P1YT24H -- CORRECT

You said in #2742 (comment) you'd like to consolidate the code path for round() and until(). FWIW, temporal-polyfill achieves this by creating an intermediate value for BOTH the PlainDate and ZonedDateTime cases, and includes ALL of the input duration's units, not just years/month/weeks. It then directly calls the equivalent of DifferenceTemporalPlainDateTime / DifferenceTemporalZonedDateTime with the relativeTo and intermediate values, delegating all balancing to those two methods.

@ptomato
Copy link
Collaborator Author

ptomato commented Jan 27, 2024

This now includes a second commit with the consolidation of the code paths for round(), total(), and until(). I'll still be auditing this before presenting to TC39 to make sure results don't change unintentionally, and to minimize unnecessary calls into user code. If that will pose a problem reviewing the materials for TC39 plenary, please let me know.

Copy link
Member

@jasonwilliams jasonwilliams left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM so far

@arshaw
Copy link
Contributor

arshaw commented Feb 5, 2024

Looks great to me. Love this round/total/until consolidation.

This consolidation fixes the bug I brought up here:
#2742 (comment)

Temporal.Duration.from({ months: 1, days: 15, hours: 12 }).total({
  unit: 'month',
  relativeTo: '2024-02-10T02:00[America/New_York]'
})
// expected: 1.4986559139784945

@ptomato
Copy link
Collaborator Author

ptomato commented Apr 2, 2024

I've rebased this and brought it up to date. Tests are in tc39/test262#4041. May still change to address some comments from @arshaw.

@ptomato ptomato force-pushed the 2742-round-discrepancy branch 3 times, most recently from 7f8b808 to 30ef811 Compare April 10, 2024 19:20
@ptomato ptomato force-pushed the 2742-round-discrepancy branch 3 times, most recently from df63308 to 2845e80 Compare May 1, 2024 16:29
@ptomato ptomato force-pushed the 2742-round-discrepancy branch 3 times, most recently from 5fb47aa to 8bb21a8 Compare May 3, 2024 21:18
When the time portion of a duration, rounded relative to a ZonedDateTime,
would land on a non-24-hour day, we'd get incorrect results. This is a
regression from #2508 where although switching the order of
BalanceDateDurationRelative and BalanceTimeDurationRelative was correct,
we should not have removed the MoveRelativeZonedDateTime call.

Closes: #2742
In order to prevent bugs due to discrepancies between two ways of
calculating the same thing such as in #2742, refactor duration rounding
with relativeTo so that

    duration.round({ smallestUnit, largestUnit, relativeTo, ...options })

goes through the same code path and gives the same result as

    const target = relativeTo.add(duration);
    relativeTo.until(target, { smallestUnit, largestUnit, ...options })

but taking into account that the until() methods have a different default
roundingMode than Duration.prototype.round(), and optimizing away as many
user-observable calls as possible.

Similarly,

    duration.total({ unit, relativeTo, ...options })

goes through the same code path, which also returns the total as a
mathematical value if needed.
@ptomato ptomato force-pushed the 2742-round-discrepancy branch 2 times, most recently from f6ad808 to 39b8616 Compare May 7, 2024 22:52
@ptomato ptomato marked this pull request as ready for review May 7, 2024 22:56
@ptomato
Copy link
Collaborator Author

ptomato commented May 7, 2024

This is ready now, addressing the opportunities for optimizing the new shared code path that @arshaw identified. Tests are in tc39/test262#4041. Review appreciated before merging.

@arshaw

This comment was marked as resolved.

@arshaw
Copy link
Contributor

arshaw commented May 9, 2024

^^^ @ptomato, nevermind, all tests are passing. reviewing now...

Copy link
Contributor

@arshaw arshaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Things look fantastic @ptomato! The only potential bug I see is with deltaDays at the end of RoundRelativeDuration. All my other feedback should be considered editorial.

In the future, we may want to detangle the total() code path from the RoundRelativeDuration as evident from the code-smelly total: NaN in NudgeToZonedTime.

There might be an opportunity to give a formal shape to "relativeTo". Kevin Ness brought this up in the last meeting regarding difficulty in statically typed languages.

type RelativeTo = {
  plainDateTime: Temporal.PlainDateTime
  timeZone: Temporal.TimeZone // or null if UTC?
}

function normalizeRelativeTo(input): RelativeTo {
  if (input instanceof Temporal.ZonedDateTime) {
    return {
      plainDateTime: input.toPlainDateTime(),
      timeZone: input.getTimeZone(),
    }
  } else if (input instanceof Temporal.PlainDate) {
    return {
      plainDateTime: input.toPlainDateTime(), // gives 00:00:00
      timeZone: new Temporal.TimeZone('UTC')
    }
  }
}

/*
Adds a Duration to the relativeTo and returns an epoch nanoseconds
This function is only ever called with time units zeroed out!
(It's just the way our nudging/bubbling system works during relative-rounding)
Thus, we don't need to worry about the intricacies of *when* time units are
added in PlainDateTime vs ZonedDateTime.

A utility function is nice because we don't need to call GetUTCEpochNanoseconds and
GetInstantFor conditionally each time.
*/
function addToRelativeTo(relativeTo: RelativeTo, duration): bigint {
  return relativeTo.timeZone.getInstantFor(
    relativeTo.plainDateTime.add(duration)
  ).epochNanoseconds
}

Thanks

polyfill/lib/duration.mjs Show resolved Hide resolved
polyfill/lib/duration.mjs Show resolved Hide resolved
polyfill/lib/ecmascript.mjs Show resolved Hide resolved
polyfill/lib/ecmascript.mjs Outdated Show resolved Hide resolved
polyfill/lib/ecmascript.mjs Show resolved Hide resolved
polyfill/lib/ecmascript.mjs Outdated Show resolved Hide resolved
polyfill/lib/ecmascript.mjs Outdated Show resolved Hide resolved
polyfill/lib/ecmascript.mjs Outdated Show resolved Hide resolved
polyfill/lib/ecmascript.mjs Outdated Show resolved Hide resolved
These optimizations were developed by Adam Shaw:
https://gist.github.com/arshaw/36d3152c21482bcb78ea2c69591b20e0

It does the same thing as previously, although fixes some incidental edge
cases that Adam discovered. However, the algorithm is simpler to explain
and to understand, and also makes fewer calls into user code.

It uses three variations on a bounding technique for rounding: computing
the upper and lower result, and checking which one is closer to the
original duration, and 'nudging' the duration up or down accordingly.

There is one variation for calendar units, one variation for rounding
relative to a ZonedDateTime where smallestUnit is a time unit and
largestUnit is a calendar unit, and one variation for time units.

RoundDuration becomes a lot more simplified, any part of it that was
complex is now split out into the new RoundRelativeDuration and
BubbleRelativeDuration operations, and the three 'nudging' operations.

The operations NormalizedTimeDurationToDays, BalanceTimeDurationRelative,
BalanceDateDurationRelative, MoveRelativeDate, MoveRelativeZonedDateTime,
and AdjustRoundedDurationDays are no longer needed. Their functionality is
subsumed by the new operations.

Closes: #2792
Closes: #2817
@ptomato
Copy link
Collaborator Author

ptomato commented May 13, 2024

OK, with the bug fixed and an extra test case added, I'm hoping this is ready to merge now. I'll plan to land it as soon as tc39/test262#4041 gets the green light.

@arshaw
Copy link
Contributor

arshaw commented May 13, 2024

Ok great, glad everything worked out. Things look good from my POV.

@ptomato
Copy link
Collaborator Author

ptomato commented May 14, 2024

OK, last outstanding change approved in February 2024 TC39 meeting, tests are merged, all review comments addressed. Thanks for the help everyone.

@ptomato ptomato merged commit d74903b into main May 14, 2024
5 checks passed
@ptomato ptomato deleted the 2742-round-discrepancy branch May 14, 2024 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Duration::round with ZonedDateTime relativeTo conflicts with ZonedDateTime::until results
4 participants