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

Inconsistent results in DifferenceISODate? #2535

Closed
anba opened this issue Mar 24, 2023 · 53 comments · Fixed by #2759
Closed

Inconsistent results in DifferenceISODate? #2535

anba opened this issue Mar 24, 2023 · 53 comments · Fixed by #2759
Assignees
Labels
meeting-agenda normative Would be a normative change to the proposal spec-text Specification text involved

Comments

@anba
Copy link
Contributor

anba commented Mar 24, 2023

The current DifferenceISODate definition returns the following results. Notice that it's always "P1M".

js> var end = new Temporal.PlainDate(1970, 2, 28)
js> var start = new Temporal.PlainDate(1970, 1, 28)
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P1M"
js> var start = new Temporal.PlainDate(1970, 1, 29)
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P1M"
js> var start = new Temporal.PlainDate(1970, 1, 30)
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P1M"
js> var start = new Temporal.PlainDate(1970, 1, 31)
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P1M"

When the end date is 1970-02-27, the results are more like what I'd expect:

js> var end = new Temporal.PlainDate(1970, 2, 27)
js> var start = new Temporal.PlainDate(1970, 1, 27)
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P1M"
js> var start = new Temporal.PlainDate(1970, 1, 28)
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P30D"
js> var start = new Temporal.PlainDate(1970, 1, 29)
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P29D"
js> var start = new Temporal.PlainDate(1970, 1, 30) 
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P28D"
js> var start = new Temporal.PlainDate(1970, 1, 31)                         
js> start.calendar.dateUntil(start, end, {largestUnit:"months"}).toString()
"P27D"

Also compare to java.time.temporal, which doesn't always return "P1M":

jshell> import java.time.LocalDate
jshell> var end = LocalDate.of(1970, 2, 28)
end ==> 1970-02-28
jshell> var start = LocalDate.of(1970, 1, 28)
start ==> 1970-01-28
jshell> start.until(end)
$4 ==> P1M
jshell> var start = LocalDate.of(1970, 1, 29)
start ==> 1970-01-29
jshell> start.until(end)
$6 ==> P30D
jshell> var start = LocalDate.of(1970, 1, 30)
start ==> 1970-01-30
jshell> start.until(end)
$8 ==> P29D
jshell> var start = LocalDate.of(1970, 1, 31)
start ==> 1970-01-31
jshell> start.until(end)
$10 ==> P28D
@justingrant
Copy link
Collaborator

justingrant commented Mar 27, 2023

You raise an interesting point. There are essentially two options for balancing a duration:

a. Only add a larger unit if the larger unit can be added without exceeding the starting date, after clamping the values of smaller units according to the default overflow: 'constrain' behavior of addition and subtraction.
b. Only add a larger unit to the result if smaller units match or exceed their value in the starting date, otherwise add smaller units instead. Per the example above, it looks like Java is using this algorithm.

@ptomato I suspect that we probably discussed this tradeoff many years ago. Do you remember the reasoning for choosing one over the other?

It looks like currently we're doing (a) to calculate from 1970-01-31 to 1970-02-28:
a. Start with 1970-01-31.
b. Try adding one month to get 1970-02-31.
c. Constrain that intermediate result to get 1970-01-28.
d. The value in (c) does not exceed the starting date, so add one month to the result.

Using the alternative algorithm (b) would look like this:
a. Start with 1970-01-31.
b. Try adding one month to to get 1970-02-31.
c. This value exceeds the starting date, so don't add one month and try smaller units instead.
d. Add 28 days to the result.

It'd be useful to understand how these two algorithmic choices would affect two other similar cases:

  • ZonedDateTime.p.since with largestUnit: 'day' and where the initial result is during the hour skipped by a forward DST transition. This case also seems to be using (a):
// DST skipped the hour 2:00-3:00 on 2020-03-08 in this time zone
Temporal.ZonedDateTime.from('2020-03-09T02:30-07:00[America/Los_Angeles]')
  .since('2020-03-08T03:30-07:00[America/Los_Angeles]', { largestUnit: 'day' });
// => P1D
  • largestUnit: 'year' with lunisolar calendar leap years (where years have different numbers of months) appears to be using (b), at least in the polyfill. Although "exceeding the starting date" is a bit fuzzy here. Should we consider the corresponding non-leap-month "equal" in a non-leap year?
// 2001 is a leap year with leap month M04L. 2000 is not a leap year.
Temporal.PlainDate.from({ year: 2001, monthCode: 'M04L', day: 1, calendar: 'chinese' })
  .until({ year: 2002, monthCode: 'M04', day: 1, calendar: 'chinese' }, { largestUnit: 'year' })
// => P12M

My initial opinion before digging into this too deeply is that it'd be good for all three of these cases to behave consistently. I don't yet have an opinion about whether (a) or (b) is the "more correct" behavior.

@gibson042 gibson042 added spec-text Specification text involved normative Would be a normative change to the proposal meeting-agenda labels Mar 30, 2023
@justingrant
Copy link
Collaborator

Meeting 2023-04-13: Let's research why we originally picked the current behavior, and once we know that history then we can figure out if the existing behavior should be changed, or if the original reason is compelling enough to deviate from Java behavior.

@ptomato
Copy link
Collaborator

ptomato commented Apr 14, 2023

Related issues:

We can probably pull up discussions in more detail in the meeting notes, but I haven't looked for those yet.

What I read in those various issue threads, consistently, is that we've considered this behaviour correct, and this is particularly important to @pipobscure:

const posDuration = earlier.until(later);
const negDuration = later.until(earlier);
earlier.add(posDuration).equals(later);
later.add(negDuration).equals(earlier);
earlier.subtract(negDuration).equals(later);
later.subtract(posDuration).equals(earlier);

@ptomato
Copy link
Collaborator

ptomato commented Apr 27, 2023

Summary of discussion on 2023-04-27:

@pipobscure's statement of commutativity is concise: A + D = B ⇔ B - D = A

In other words, for two dates A and B, and a duration D, if A.add(D) is B then B.subtract(D) is A, and vice versa. Additionally A.until(B) and B.since(A) are D, although that doesn't necessarily have to follow, depending on your definition.

The example in the OP is one where commutativity does not hold, because of the default overflow: "constrain" behaviour. This is why we have the option for overflow: "reject", so you can weed out those cases if you don't want them.

However, the inconsistent behaviour for the 27th versus the 28th of the month looks suspicious. It might just be a bug, in the first place. We still need to research this.

@justingrant justingrant self-assigned this May 11, 2023
@gibson042
Copy link
Collaborator

Unless I'm misinterpreting, commutativity would hold if all the cases where start day-of-month is greater than end day-of-month would return a duration in which the largest unit is days, as seems to be the case for Java. I think the culprit in Temporal's divergence is the inherent privileging of last-day-of-month by the embedded "constrain" in step 1.k of DifferenceISODate (and presumably also in step 1.n.iii for other similar cases).

@justingrant
Copy link
Collaborator

justingrant commented Sep 22, 2023

I'm finally done researching this. Sorry for delays. TL;DR - I think the current spec is correct, given the criteria used when we designed the algorithm about 3 years ago.

Some relevant meeting discussion is here: https://github.com/tc39/proposal-temporal/blob/main/meetings/agenda-minutes-2020-10-15.md#anything-left-to-discuss-on-order-of-operations-993--913

The background for decisions in this issue is best captured here: #993 (comment). That comment is still a bit out of date (it refers to an older operation difference, instead of the modern until and since names that we ended up using) but the design thinking in that comment is good context to understand my comments below. You may want to read the subsequent discussion in that issue after the comment, because it goes into some corner cases, including difference in behavior from Java.

In other words, for two dates A and B, and a duration D, if A.add(D) is B then B.subtract(D) is A

Regardless of how this issue is resolved, this invariant cannot hold for all values of A, B, and D. This is because add and subtract will constrain the result. For example,

a = Temporal.PlainDate.from('2023-01-31')
d = Temporal.Duration.from({months: 1});
b = a.add(d); // => 2020-02-28 (constrained!)
b.subtract(d); // => 2020-01-28 
a.equals(b.subtract(d)); // => false

However, different invariants should hold in the current design:

  • if A.until(B, {largestUnit: 'month'}) is D, then B.subtract(D) is A
  • if A.since(B, , {largestUnit: 'month'}) is D, then B.add(D) is A

Assuming that my memory is correct, making these invariants work and enabling reversibility was the reason why we wrote the DifferenceISODate algorithm like we did. There may have been other reasons too.

So I don't think that a spec change is needed, unless there's some new information that would lead us to de-prioritize reversibility in favor of some other reason (e.g. Java compatibility) or unless the reversible invariants above don't hold with the current spec.

I'm not saying that the current spec yields perfect results. The results shown in the OP are indeed confusing. But I think the high-level point is that all results are potentially confusing, depending on what the user's expectations are and what use cases are being targeted. There is no perfect solution here, only less-bad solutions for some users and some use cases.

So the approach we took in Temporal was to: a) optimize the algorithm for reversibility, and b) set the default largestUnit to days or smaller for all Temporal types (except PlainYearMonth, where this problem doesn't apply). This default ensures that the confusion inherent in arithmetic with variable-length months doesn't show up unless the user opts into a non-default largestUnit.

Feel free to push back if I got anything wrong above!

@ptomato
Copy link
Collaborator

ptomato commented Sep 22, 2023

That's great news. I read through the discussion and what you described (optimizing for reversibility and defaulting largestUnit to days) matches my recollection as well. Thanks for looking into it!

@anba
Copy link
Contributor Author

anba commented Sep 22, 2023

However, different invariants should hold in the current design:

* if `A.until(B, {largestUnit: 'month'})` is `D`, then `B.subtract(D)` is `A`

* if `A.since(B, , {largestUnit: 'month'})` is `D`, then `B.add(D)` is `A`

Assuming that my memory is correct, making these invariants work and enabling reversibility was the reason why we wrote the DifferenceISODate algorithm like we did. There may have been other reasons too.

So I don't think that a spec change is needed, unless there's some new information that would lead us to de-prioritize reversibility in favor of some other reason (e.g. Java compatibility) or unless the reversible invariants above don't hold with the current spec.

These invariants don't hold with the current spec. For example:

A = 1972-01-02
B = 1972-03-01

Then D = P1M28D, but B - D = 1972-01-04

Script to test the invariants:

function* dates() {
  for (let y = 1972; y <= 1973; ++y) {
    for (let m = 1; m <= 12; ++m) {
      for (let d = 1; d <= 31; ++d) {
        try {
          yield new Temporal.PlainDate(y, m, d);
        } catch {}
      }
    }
  }
}

function assertEq(a, e, m) {
  if (a !== e) console.log(`${m}: ${a} != ${e}`);
}

for (let a of dates()) {
  for (let b of dates()) {
    let d = a.until(b, {largestUnit: "month"});
    let e = b.subtract(d);

    assertEq(e.toString(), a.toString(), `a=${a}, b=${b}, d=${d}, e=${e}`);
  }
}

@gibson042
Copy link
Collaborator

The issue description clearly demonstrates a case where currently specified behavior is less useful than a particular alternative, with the alternative actually being more reversible. In what cases is there a compensating benefit—and if none can be identified, then what would discourage switching to what seems like a strictly superior approach? This is advertised as a fundamental reason to introduce Temporal, so getting it wrong would undermine quite a bit and approach catastrophe.

@gibson042
Copy link
Collaborator

During discussion today, I came to the conclusion that since and until should both behave such that varying one input while keeping the other constant should always produce distinguishable non-colliding output, regardless of largestUnit (but in the absence of rounding, which by definition produces collisions).

@justingrant
Copy link
Collaborator

Meeting 2023-10-12:

  • Summary of current state: Temporal behavior is different from Java, the behavior is unintuitive for at least one person who has dug into it, and the behavior in the spec doesn't seem to match what we thought one of the intended invariants was. So it's worth more investigation and, depending on outcome, maybe a normative change in the November TC39 meeting.
  • I'll investigate why the current spec doesn't match the invariants that we thought it should. Was it a bug in writing down the algorithm text, or was it a more fundamental issue in the design of the algorithm? I'll try to dig up more of the original prose to express the algorithm (I think this was part of my work on ZonedDateTime design) and report back what I find out.
  • @ptomato will investigate how other platforms' date libraries behave. If everybody matches Java except us, then that's a strong hint that we got something wrong. And if we want to follow Java, then we'll need to either reverse-engineer Java's algorithm or (better) find it in the JSR-310 spec.
  • We'll discuss findings in the next champions meeting in 2 weeks, and if a change is needed then we can propose the change in the November TC39 meeting.

@justingrant
Copy link
Collaborator

since and until should both behave such that varying one input while keeping the other constant should always produce distinguishable non-colliding output, regardless of largestUnit (but in the absence of rounding, which by definition produces collisions).

@gibson042 Would this requirement ever lead to durations with unexpectedly large unit values? I'm not saying it would, only wondering if there are edge cases where results like P1M32D could happen.

@gibson042
Copy link
Collaborator

I don't think that would be possible in the ISO 8601 calendar, because the 32D part would always be equivalent to 1MnD where n is small, and therefore the whole duration would instead be P2MnD. But it would be possible in a calendar with two consecutive short months—for example, if consecutive months W, X, Y, and Z respectively had 31, 31, 30, and 30 days, then Z30.since(W31) would be "P1M60D" (1M [to X31] + 30D [to Y30] + 30D [to Z30]).

@ptomato
Copy link
Collaborator

ptomato commented Oct 25, 2023

Here are the libraries/platforms that I looked into.

tl;dr The majority of them don't even support this kind of calculation with largestUnit months. Temporal is part of a small minority that do. Other JS libraries match Temporal's behaviour. Java and ICU4X are different from Temporal and different from each other.

JavaScript

  • Temporal, of course.
  • Moment.js, described below.
  • Luxon, described below.
  • Date-fns can't convert days to months in durations, so no direct comparison is possible.

Python

  • The standard library's datetime does not admit durations with month components, so no direct comparison is possible.

C

  • GLib's GDate can only calculate differences in days, and GDateTime only in microseconds, so no direct comparison is possible.

C++

  • The standard library's chrono doesn't do calendar math with chrono::year_month_day. You have to convert to a chrono::time_point and do exact time math, where they use "months" that have a constant length of ≈30.4 days. So no direct comparison is possible.
  • boost::gregorian::date does not do differences in months. However, you can add and subtract months, but see Reversibility of Operations Pitfall under boost::gregorian::days; they have a snap-to-end-of-month behaviour when adding/subtracting months, so 1970-02-28 + 1 month = 1970-03-31! So this is entirely different behaviour than Temporal.
  • Abseil Time library does not admit durations with month components, so no direct comparison is possible.

C#

  • The standard library's TimeSpan does not admit durations with month components, so no direct comparison is possible.

Rust

  • ICU4X, described below.
  • The chrono crate does not admit durations with month components, so no direct comparison is possible. However, there are add-on crates which do calendar arithmetic. I haven't found any that look officially recommended/supported. I tried date_component with chrono and it behaves similar to Java but returns durations like 4 weeks 3 days instead of 31 days.

Java

  • The standard library java.time, as in the original post.

(Note, I'm not too familiar with most of these libraries so I may well have missed some capabilities. Please let me know if I came to the wrong conclusions. Also let me know if I missed one that you think I should look into.)

date1 date2 largestUnit Temporal Moment Luxon ICU4X Java
1970-01-28 1970-02-28 months 1 m 1 m 1 m 1 m 1 m
1970-01-28 1970-02-28 days 31 d 31 d 31 d 1 m 31 d
1970-01-29 1970-02-28 months 1 m 1 m 1 m 1 m -1 d 30 d
1970-01-29 1970-02-28 days 30 d 30 d 30 d 1 m -1 d 30 d
1970-01-30 1970-02-28 months 1 m 1 m 1 m 1 m -2 d 29 d
1970-01-30 1970-02-28 days 29 d 29 d 29 d 1 m -2 d 29 d
1970-01-31 1970-02-28 months 1 m 1 m 1 m 1 m -3 d 28 d
1970-01-31 1970-02-28 days 28 d 28 d 28 d 1 m -3 d 28 d
1970-01-27 1970-02-27 months 1 m 1 m 1 m 1 m 1 m
1970-01-27 1970-02-27 days 31 d 31 d 31 d 1 m 31 d
1970-01-28 1970-02-27 months 30 d 0 m 30 d 1 m -1 d 30 d
1970-01-28 1970-02-27 days 30 d 30 d 30 d 1 m -1 d 30 d
1970-01-29 1970-02-27 months 29 d 0 m 29 d 1 m -2 d 29 d
1970-01-29 1970-02-27 days 29 d 29 d 29 d 1 m -2 d 29 d
1970-01-30 1970-02-27 months 28 d 0 m 28 d 1 m -3 d 28 d
1970-01-30 1970-02-27 days 28 d 28 d 28 d 1 m -3 d 28 d

Moment and Luxon behave the same as Temporal. I found an old PR suggesting that they struggled with "buggy month/year diffs" and that would at least suggest that this behaviour is intentional. Maybe @maggiepint or @mattjohnsonpint might know more about this.

The one difference is that Moment returns 0 months when asking for a difference in months where Temporal and Luxon return a number of days instead, giving the "0 months" entries in the bottom of the table. This makes sense because they don't have a duration type, it's just a number.

Date arithmetic with icu::calendar::Date::until() is unstable in the current version of ICU4X. It differs quite a lot from the arithmetic we're looking at in Temporal, because it admits mixed signs in durations. The largest unit parameter also seems to make no difference, so perhaps that's a bug in the unstable version.

I found an interesting discussion on Reddit while I was searching for Rust datetime libraries. My takeaway from there is that this could be unintuitive either way depending on your application.

My conclusion is that there isn't one obviously correct behaviour here, and that we may as well match other JS libraries by keeping the status quo, which wasn't arbitrarily chosen.

Code snippets

Temporal
for (const base of [28, 27]) {
  let end = new Temporal.PlainDate(1970, 2, base);
  for (const startDay of [base, base + 1, base + 2, base + 3]) {
    for (const largestUnit of ['months', 'days']) {
      let start = new Temporal.PlainDate(1970, 1, startDay);
      const result = start.until(end, { largestUnit });
      console.log(`${start} .. ${end} = ${result} in ${largestUnit}`);
    }
  }
}
Moment
import moment from 'moment';
	  
for (const base of [28, 27]) {
  let end = moment.utc([1970, 1, base]);
  for (const startDay of [base, base + 1, base + 2, base + 3]) {
    for (const largestUnit of ['months', 'days']) {
      let start = moment.utc([1970, 0, startDay]);
      const result = end.diff(start, largestUnit);
      console.log(`${start.format('YYYY-MM-DD')} .. ${end.format('YYYY-MM-DD')} = ${result} in ${largestUnit}`);
    }
  }
}
Luxon
import {DateTime} from 'luxon';

for (const base of [28, 27]) {
  let end = DateTime.utc(1970, 2, base);
  for (const startDay of [base, base + 1, base + 2, base + 3]) {
    for (const largestUnit of [['months', 'days'], 'days']) {
      let start = DateTime.utc(1970, 1, startDay);
      const result = end.diff(start, largestUnit);
      console.log(`${start.toISODate()} .. ${end.toISODate()} = ${result.toHuman()} in ${largestUnit}`);
    }
  }
}
ICU4X
use icu::calendar::{Date, DateDurationUnit};
use icu::datetime::{options::length, DateFormatter};
use icu::locid::locale;
	  
fn main() {
    for base in (27..=28).rev() {
        let formatter =
            DateFormatter::try_new_with_length(&locale!("en-CA").into(), length::Date::Short)
                .unwrap();

        let end = Date::try_new_iso_date(1970, 2, base).unwrap();
        let end_str = formatter.format_to_string(&end.to_any()).unwrap();
        for start_day in base..base + 4 {
            let start = Date::try_new_iso_date(1970, 1, start_day).unwrap();
            // Note: until() is unstable. Arguments are reversed
            let result_in_months =
                end.until(&start, DateDurationUnit::Months, DateDurationUnit::Days);
            let start_str = formatter.format_to_string(&start.to_any()).unwrap();
            println!(
                "{} .. {} = {} m {} d in months",
                start_str, end_str, result_in_months.months, result_in_months.days
            );
            let result_in_days = end.until(&start, DateDurationUnit::Days, DateDurationUnit::Days);
            println!(
                "{} .. {} = {} m {} d in days",
                start_str, end_str, result_in_days.months, result_in_months.days
            );
        }
    }
}
java.time
import java.time.LocalDate;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.stream.IntStream;

class Application {
    public static void main(String[] args) {
        for (int base : new int[] { 28, 27 }) {
            LocalDate end = LocalDate.of(1970, 2, base);
            for (int startDay : IntStream.range(base, base + 4).toArray()) {
                LocalDate start = LocalDate.of(1970, 1, startDay);
                Period result = start.until(end);
                long monthResult = start.until(end, ChronoUnit.MONTHS);
                System.out.printf("%s .. %s = %d in months (%s)\n", start, end, monthResult, result);
                long dayResult = start.until(end, ChronoUnit.DAYS);
                System.out.printf("%s .. %s = %d in days\n", start, end, dayResult);
            }
        }
    }
}

@justingrant
Copy link
Collaborator

I'll investigate why the current spec doesn't match the invariants that we thought it should. Was it a bug in writing down the algorithm text, or was it a more fundamental issue in the design of the algorithm? I'll try to dig up more of the original prose to express the algorithm (I think this was part of my work on ZonedDateTime design) and report back what I find out.

I did some basic research to validate that the invariants above:

  • if A.until(B, { largestUnit: 'month' }) is D, then B.subtract(D) is A
  • if A.since(B, { largestUnit: 'month' }) is D, then B.add(D) is A

I found that our tests confirm those invariants:

const dif = two.since(one, { largestUnit });
const overflow = 'reject';
if (largestUnit === 'months' || largestUnit === 'years') {
// For months and years, `until` and `since` won't agree because the
// starting point is always `this` and month-aware arithmetic behavior
// varies based on the starting point.
it(`(${two}).subtract(${dif}) => ${one}`, () => assert(two.subtract(dif).equals(one)));
it(`(${two}).add(-${dif}) => ${one}`, () => assert(two.add(dif.negated()).equals(one)));
const difUntil = one.until(two, { largestUnit });
it(`(${one}).subtract(-${difUntil}) => ${two}`, () => assert(one.subtract(difUntil.negated()).equals(two)));
it(`(${one}).add(${difUntil}) => ${two}`, () => assert(one.add(difUntil).equals(two)));
} else {

My conclusions so far:

  • The invariants above are indeed what was intended
  • The algorithm (at least as implemented in the polyfill; I didn't verify with spec text) doesn't satisfy those invariants
  • There's a gap in our datemath.mjs tests (and I assume Test262 too) because we didn't catch this issue earlier.

@justingrant
Copy link
Collaborator

Meeting 2023-10-26:

  • There are (at least) three possible paths forward.
    • Option 1: if A.until(B, {largestUnit: 'month'}) is D, then B.subtract(D) is A
      • This was the intended behavior but, due to a test gap and spec bug, our algorithm doesn't satisfy this invariant.
      • If we want to revise the algorithm to match this invariant, then we need to figure out why the current spec text doesn't satisfy it, and to figure out if there is any algorithm that meets this invariant.
    • Option 2: align behavior with Moment and Luxon (no normative change; leave current behavior as-is)
      • @ptomato will try to come up with a simple invariant that describes the current algorithm, along the lines of the invariant in (1).
      • @ptomato will reach out to @maggiepint and @mattjohnsonpint to see if they have context from their Moment days.
    • Option 3: since and until should both behave such that varying one input while keeping the other constant should always produce distinguishable non-colliding output
  • @sffc suggested that everyone should suggest use cases, so please add them as comments!
  • We'll discuss the research from (2) and (3) at the next champions' meeting, and plan to make a decision at that point, using the following decision path:
    • If the research into (2) and (3) (and/or use cases proposed) yields one obvious winner, then we’ll use that.
    • Otherwise, we’ll fall back to the intended behavior (1).
      • If we can improve the algorithm so that it satisfies the invariant from (1), then make that change.
      • If no algorithm can satisfy (1), then leave the current spec as-is.

@sffc
Copy link
Collaborator

sffc commented Oct 28, 2023

Can someone explain to me why we don't just use the following algorithm:

Assumptions for illustration purposes:

  1. largestUnit is either "months" or "years" (days is easy)
  2. (y1, m1, d1) < (y2, m2, d2) -- haven't thought yet about how it generalizes

Algorithm for DifferenceISODate(y1, m1, d1, y2, m2, d2, largestUnit)

  1. Assert: (y1, m1, d1) < (y2, m2, d2)
  2. Let yd = y2 - y1
  3. Let md = m2 - m1
  4. Let dd = d2 - d1
  5. Assert: yd >= 0
  6. If largestUnit == "months" and yd > 0:
    1. md += yd * 12
    2. yd = 0
  7. If dd < 0:
    1. md -= 1
    2. dd += "number of days between (y2, m2 - 1, d2) and (y2, m2, d2)"
  8. If md < 0:
    1. yd -= 1
    2. md += 12
  9. Assert: yd >= 0, md >= 0, dd >= 0
  10. Return Duration(yd, md, dd)

Code: https://jsbin.com/zunaliriki/1/edit?js,console

function DifferenceISODate(y1, m1, d1, y2, m2, d2, largestUnit) {
  console.assert(
    largestUnit === "years" ||
    largestUnit === "months" ||
    largestUnit === "days"
  );
  if (largestUnit === "days") {
    return DaysBetweenISO(y1, m1, d1, y2, m2, d2);
  }
  let yd = y2 - y1;
  let md = m2 - m1;
  let dd = d2 - d1;
  console.assert(yd >= 0); // because date 1 < date 2
  if (largestUnit === "months" && yd > 0) {
    md += yd * 12;
    yd = 0;
  }
  if (dd < 0) {
    md -= 1;
    dd += DaysBetweenISO(y2, m2 - 1, d2, y2, m2, d2);
  }
  if (md < 0) {
    yd -= 1;
    md += 12;
  }
  console.assert(yd >= 0);
  console.assert(md >= 0);
  console.assert(dd >= 0);
  console.log(`${y1}-${m1}-${d1} to ${y2}-${m2}-${d2}: ${yd}y ${md}m ${dd}d (${largestUnit})`);
}

function DaysBetweenISO(y1, m1, d1, y2, m2, d2) {
return Math.round(
(new Date(y2, m2-1, d2) - new Date(y1, m1-1, d1)) / (1000360024)
);
}

DifferenceISODate(2020, 3, 15, 2020, 4, 13, "years");
DifferenceISODate(2020, 3, 15, 2020, 4, 14, "years");
DifferenceISODate(2020, 3, 15, 2020, 4, 15, "years");
DifferenceISODate(2020, 3, 15, 2020, 4, 16, "years");
DifferenceISODate(2020, 3, 15, 2020, 4, 17, "years");
DifferenceISODate(2020, 3, 15, 2020, 4, 30, "years");
DifferenceISODate(2020, 3, 15, 2020, 5, 1, "years");
DifferenceISODate(2020, 3, 15, 2020, 5, 13, "years");
DifferenceISODate(2020, 3, 15, 2020, 5, 14, "years");
DifferenceISODate(2020, 3, 15, 2020, 5, 15, "years");
DifferenceISODate(2020, 3, 15, 2020, 5, 16, "years");
DifferenceISODate(2020, 3, 15, 2020, 5, 17, "years");
DifferenceISODate(2020, 3, 15, 2021, 2, 13, "years");
DifferenceISODate(2020, 3, 15, 2021, 2, 14, "years");
DifferenceISODate(2020, 3, 15, 2021, 2, 15, "years");
DifferenceISODate(2020, 3, 15, 2021, 2, 16, "years");
DifferenceISODate(2020, 3, 15, 2021, 2, 17, "years");
DifferenceISODate(2020, 3, 15, 2021, 2, 28, "years");
DifferenceISODate(2020, 3, 15, 2021, 3, 1, "years");
DifferenceISODate(2020, 3, 15, 2021, 3, 13, "years");
DifferenceISODate(2020, 3, 15, 2021, 3, 14, "years");
DifferenceISODate(2020, 3, 15, 2021, 3, 15, "years");
DifferenceISODate(2020, 3, 15, 2021, 3, 16, "years");
DifferenceISODate(2020, 3, 15, 2021, 3, 17, "years");
DifferenceISODate(2020, 3, 15, 2021, 3, 31, "years");
DifferenceISODate(2020, 3, 15, 2021, 4, 1, "years");
DifferenceISODate(2020, 3, 15, 2021, 4, 13, "years");
DifferenceISODate(2020, 3, 15, 2021, 4, 14, "years");
DifferenceISODate(2020, 3, 15, 2021, 4, 15, "years");
DifferenceISODate(2020, 3, 15, 2021, 4, 16, "years");
DifferenceISODate(2020, 3, 15, 2021, 4, 17, "years");
DifferenceISODate(2020, 3, 15, 2021, 2, 13, "months");
DifferenceISODate(2020, 3, 15, 2021, 2, 14, "months");
DifferenceISODate(2020, 3, 15, 2021, 2, 15, "months");
DifferenceISODate(2020, 3, 15, 2021, 2, 16, "months");
DifferenceISODate(2020, 3, 15, 2021, 2, 17, "months");
DifferenceISODate(2020, 3, 15, 2021, 2, 28, "months");
DifferenceISODate(2020, 3, 15, 2021, 3, 1, "months");
DifferenceISODate(2020, 3, 15, 2021, 3, 13, "months");
DifferenceISODate(2020, 3, 15, 2021, 3, 14, "months");
DifferenceISODate(2020, 3, 15, 2021, 3, 15, "months");
DifferenceISODate(2020, 3, 15, 2021, 3, 16, "months");
DifferenceISODate(2020, 3, 15, 2021, 3, 17, "months");
DifferenceISODate(2020, 3, 15, 2021, 3, 31, "months");
DifferenceISODate(2020, 3, 15, 2021, 4, 1, "months");
DifferenceISODate(2020, 3, 15, 2021, 4, 13, "months");
DifferenceISODate(2020, 3, 15, 2021, 4, 14, "months");
DifferenceISODate(2020, 3, 15, 2021, 4, 15, "months");
DifferenceISODate(2020, 3, 15, 2021, 4, 16, "months");
DifferenceISODate(2020, 3, 15, 2021, 4, 17, "months");

@ptomato
Copy link
Collaborator

ptomato commented Nov 4, 2023

Can someone explain to me why we don't just use the following algorithm:

That might actually be equivalent to the algorithm we use in the case where (y1, m1, d1) < (y2, m2, d2), but it does actually matter how we generalize it to the other way around.

For example

earlier = Temporal.PlainDate.from('2023-03-25')
later = Temporal.PlainDate.from('2023-07-05')
earlier.until(later, {largestUnit: 'months'})

This is 3 months, 10 days according to both the current DifferenceISODate and the algorithm from the above comment. Arithmetic is relative to the receiver, so to verify the invariant we add 3 months to 2023-03-25, getting 2023-06-25, then we add 10 days to 2023-06-25, getting 2023-07-05.
Conversely,

later.since(earlier, {largestUnit: 'months'})

This is 3 months, 11 days according to the current DifferenceISODate. To verify the invariant we subtract 3 months from 2023-07-05, getting 2023-04-05, then we subtract 11 days from 2023-04-05, getting 2023-03-25. We'd need to decide how to handle this case with the above algorithm, and doing so may well give an algorithm that's equivalent to the current one.

@ptomato
Copy link
Collaborator

ptomato commented Nov 8, 2023

@ptomato will try to come up with a simple invariant that describes the current algorithm, along the lines of the invariant in (1).

Here's what I've managed to come up with.

  • Old: since and until should both behave such that varying one input while keeping the other constant should always produce distinguishable non-colliding output, regardless of largestUnit, and in the absence of rounding.
  • New: since and until should both behave such that varying one input while keeping the other constant should always produce distinguishable non-colliding output, with any non-calendar largestUnit, and in the absence of rounding.
  • Old: If A.until(B) is D, then B.subtract(D) is A, regardless of largestUnit. If A.since(B) is D, then B.add(D) is A, regardless of largestUnit.
  • New: If A.until(B) is D, then B.subtract(D) is A, for non-calendar largestUnit. If A.since(B) is D, then B.add(D) is A, for non-calendarlargestUnit.
  • New: If A.until(B) is D, then A.add(D) is B, regardless of largestUnit. If A.since(B) is D, then A.subtract(D) is B, regardless of largestUnit.

(Preferably I wanted to include something about how with overflow: 'reject' the old invariants would still either hold or throw due to overflow. But I don't think that's actually possible; the old invariants are just broken because weeks, months, and years are different lengths, depending on your starting point, and your starting point is always the this-object.)

@gibson042
Copy link
Collaborator

gibson042 commented Nov 9, 2023

@gibson042 will try to define an algorithm that passes for the same pairs of dates from @anba's tests in Inconsistent results in DifferenceISODate? #2535 (comment)

OK, I think I've got it. This should really be subject to near-exhaustive testing of every date in a four-year cycle to every other, but I have not done so here. Expressed as JavaScript:

const DifferenceISODate = (y1, m1, d1, y2, m2, d2, largestUnit = "year") => {
  if (largestUnit === "year" || largestUnit === "month") {
    const sign = (y2 < y1) || (y2 === y1 && m2 < m1) || (y2 === y1 && m2 === m1 && d2 < d1) ? -1 : 1;
    if (sign < 0) {
      [y1, m1, d1, y2, m2, d2] = [y2, m2, d2, y1, m1, d1];
    }
    assert((y1 < y2) || (y1 === y2 && m1 < m2) || (y1 === y2 && m1 === m2 && d1 <= d2));
    let years = y2 - y1;
    if (m2 < m1 || (m2 === m1 && d2 < d1)) years--;
    let months = (m2 - m1 + 12) % 12;
    let days = d2 - d1;
    if (days < 0) {
      let m = m2;
      do {
        m = (m - 1) || 12;
        months--;
        days += ISODaysInMonth(y2, m);
      } while (d1 > ISODaysInMonth(y2, m));
      while (months < 0) {
        years--;
        months += 12;
      }
    }
    if (largestUnit === "month") {
      months += years * 12;
      years = 0;
    }
    if (sign < 0) [years, months, days] = [-years, -months, -days];
    return { years, months, days };
  } else {
    assert(largestUnit === "day" || largestUnit === "week");
    // ...
  }
};

const pairs = [
  ...[27, 28, 29, 30, 31].map(day => [`1970-01-${day}`, "1970-02-27"]),
  ...[27, 28, 29, 30, 31].map(day => [`1970-01-${day}`, "1970-02-28"]),
  ["1970-01-02", "1970-03-01"],
  ["1972-01-02", "1972-03-01"],
  ["1971-12-02", "1972-03-01"],
  ["1971-12-30", "1972-03-01"],
  ["1971-12-30", "1972-03-10"],
  ["1969-12-30", "1972-03-10"],
];
for(const [start, end] of pairs) {
  const [[y1, m1, d1], [y2, m2, d2]] = [start, end].map(ymd =>
    ymd.match(/^0*(\d+)-0*(\d+)-0*(\d+)$/).slice(1).map(x => +x)
  );
  console.log(`${start} to ${end}`, DifferenceISODate(y1, m1, d1, y2, m2, d2, "year"));
}
/* =>
1970-01-27 to 1970-02-27 { years: 0, months: 1, days: 0 }
1970-01-28 to 1970-02-27 { years: 0, months: 0, days: 30 }
1970-01-29 to 1970-02-27 { years: 0, months: 0, days: 29 }
1970-01-30 to 1970-02-27 { years: 0, months: 0, days: 28 }
1970-01-31 to 1970-02-27 { years: 0, months: 0, days: 27 }
1970-01-27 to 1970-02-28 { years: 0, months: 1, days: 1 }
1970-01-28 to 1970-02-28 { years: 0, months: 1, days: 0 }
1970-01-29 to 1970-02-28 { years: 0, months: 0, days: 30 }
1970-01-30 to 1970-02-28 { years: 0, months: 0, days: 29 }
1970-01-31 to 1970-02-28 { years: 0, months: 0, days: 28 }
1970-01-02 to 1970-03-01 { years: 0, months: 1, days: 27 }
1972-01-02 to 1972-03-01 { years: 0, months: 1, days: 28 }
1971-12-02 to 1972-03-01 { years: 0, months: 2, days: 28 }
1971-12-30 to 1972-03-01 { years: 0, months: 1, days: 31 }
1971-12-30 to 1972-03-10 { years: 0, months: 1, days: 40 }
1969-12-30 to 1972-03-10 { years: 2, months: 1, days: 40 }
*/

@ptomato
Copy link
Collaborator

ptomato commented Nov 11, 2023

This should really be subject to near-exhaustive testing of every date in a four-year cycle to every other, but I have not done so here.

I wrote a script to do this, and found some differences that I think must be unintentional. There seem to be some date pairs where the result is off by one year.

Here are the first few:

1970-01-02..1971-01-01 in months: old 11 m, 30 d, new -1 m, 30 d
1970-01-02..1971-01-01 in years: old 11 m, 30 d, new -1 m, 30 d
1970-01-02..1972-01-01 in months: old 23 m, 30 d, new 11 m, 30 d
1970-01-02..1972-01-01 in years: old 1 y, 11 m, 30 d, new 11 m, 30 d
1970-01-02..1973-01-01 in months: old 35 m, 30 d, new 23 m, 30 d
1970-01-02..1973-01-01 in years: old 2 y, 11 m, 30 d, new 1 y, 11 m, 30 d
1970-01-02..1974-01-01 in months: old 47 m, 30 d, new 35 m, 30 d
1970-01-02..1974-01-01 in years: old 3 y, 11 m, 30 d, new 2 y, 11 m, 30 d
Here's the script
import { strict as assert } from 'assert';
import fs from 'node:fs';
import ProgressBar from 'progress';

import {
  BalanceISODate,
  DifferenceISODate as diffOld,
  ISODateTimePartString,
  ISODaysInMonth
} from './lib/ecmascript.mjs';

function diffNew(y1, m1, d1, y2, m2, d2, largestUnit) {
  if (largestUnit === 'year' || largestUnit === 'month') {
    const sign = y2 < y1 || (y2 === y1 && m2 < m1) || (y2 === y1 && m2 === m1 && d2 < d1) ? -1 : 1;
    if (sign < 0) {
      [y1, m1, d1, y2, m2, d2] = [y2, m2, d2, y1, m1, d1];
    }
    assert(y1 < y2 || (y1 === y2 && m1 < m2) || (y1 === y2 && m1 === m2 && d1 <= d2));
    let years = y2 - y1;
    if (m2 < m1 || (m2 === m1 && d2 < d1)) years--;
    let months = (m2 - m1 + 12) % 12;
    let days = d2 - d1;
    if (days < 0) {
      let m = m2;
      do {
        m = m - 1 || 12;
        months--;
        days += ISODaysInMonth(y2, m);
      } while (d1 > ISODaysInMonth(y2, m));
      while (months < 0) {
        years--;
        months += 12;
      }
    }
    if (largestUnit === 'month') {
      months += years * 12;
      years = 0;
    }
    if (sign < 0) [years, months, days] = [-years, -months, -days];
    return { years, months, days };
  } else {
    assert(largestUnit === 'day' || largestUnit === 'week');
    // ...
  }
}

function fdate(y, m, d) {
  return `${y}-${ISODateTimePartString(m)}-${ISODateTimePartString(d)}`;
}

function fdur({ years, months, days }) {
  let result = '';
  if (years) result += `${years} y, `;
  if (months) result += `${months} m, `;
  result += `${days} d`;
  return result;
}

const progress = new ProgressBar(':bar :percent (:current/:total) | :etas', {
  total: 1461 ** 2,
  complete: '\u2588',
  incomplete: '\u2591',
  width: 20,
  stream: process.stdout,
  renderThrottle: 50,
  clear: true
});

const fd = fs.openSync('./exhaust.txt', 'w');

let sameCount = 0;
let differentCount = 0;

for (let i = 0; i <= 1461; i++) {
  const { year: y1, month: m1, day: d1 } = BalanceISODate(1970, 1, i + 1);
  for (let j = 0; j <= 1461; j++) {
    const { year: y2, month: m2, day: d2 } = BalanceISODate(1970, 1, j + 1);
    for (const largestUnit of ['month', 'year']) {
      const old = diffOld(y1, m1, d1, y2, m2, d2, largestUnit);
      const nu = diffNew(y1, m1, d1, y2, m2, d2, largestUnit);

      if (old.years !== nu.years || old.months !== nu.months || old.days !== nu.days) {
        fs.writeSync(
          fd,
          `${fdate(y1, m1, d1)}..${fdate(y2, m2, d2)} in ${largestUnit}s: old ${fdur(old)}, new ${fdur(nu)}\n`
        );
        differentCount++;
      } else {
        sameCount++;
      }
    }

    progress.tick();
  }
}

fs.closeSync(fd);

console.log('same', sameCount, 'different', differentCount);

@ptomato
Copy link
Collaborator

ptomato commented Nov 11, 2023

While I was writing exhaustive scripts, I also verified the invariants I proposed, with the status quo algorithm:

  1. since and until should both behave such that varying one input while keeping the other constant should always produce distinguishable non-colliding output, with any non-calendar largestUnit, and in the absence of rounding.
  • This is trivially verifiable because non-calendar largestUnit is "days".
  1. If A.until(B) is D, then B.subtract(D) is A, for non-calendar largestUnit.
  2. If A.since(B) is D, then B.add(D) is A, for non-calendar largestUnit.
  3. If A.until(B) is D, then A.add(D) is B, regardless of largestUnit.
  4. If A.since(B) is D, then A.subtract(D) is B, regardless of largestUnit.
  • These four are verified with the script below.
Here's the script
import { strict as assert } from 'assert';
import ProgressBar from 'progress';

import * as Temporal from './lib/temporal.mjs';

const base = Temporal.PlainDate.from('1970-01-01');

const progress = new ProgressBar(':bar :percent (:current/:total) | :etas', {
  total: 1461 ** 2,
  complete: '\u2588',
  incomplete: '\u2591',
  width: 20,
  stream: process.stdout,
  renderThrottle: 50,
  clear: true
});

for (let i = 0; i <= 1461; i++) {
  const a = base.add({ days: i });
  for (let j = 0; j <= 1461; j++) {
    const b = base.add({ days: j });
    for (const largestUnit of ['weeks', 'months', 'years']) {
      // Invariant: if A.until(B) is D, then A.add(D) is B, for any largestUnit.
      {
        const d = a.until(b, { largestUnit });
        const b2 = a.add(d);
        assert.equal(b2.toString(), b.toString(), `${a}.until(${b}, ${largestUnit}) == ${d}, ${a}.add(${d}) == ${b2}`);
      }

      // Invariant: if A.since(B) is D, then A.subtract(D) is B, for any largestUnit.
      {
        const d = a.since(b, { largestUnit });
        const b2 = a.subtract(d);
        assert.equal(
          b2.toString(),
          b.toString(),
          `${a}.since(${b}, ${largestUnit}) == ${d}, ${a}.subtract(${d}) == ${b2}`
        );
      }
    }

    {
      const d = a.until(b);
      const b2 = a.add(d);
      assert.equal(b2.toString(), b.toString(), `${a}.until(${b}) == ${d}, ${a}.add(${d}) == ${b2}`);

      // Invariant: if A.until(B) is D, then B.subtract(D) is A, for any non-calendar largestUnit.
      const a2 = b.subtract(d);
      assert.equal(a2.toString(), a.toString(), `${a}.until(${b}) == ${d}, ${b}.subtract(${d}) == ${a2}`);
    }

    {
      const d = a.since(b);
      const b2 = a.subtract(d);
      assert.equal(b2.toString(), b.toString(), `${a}.since(${b}) == ${d}, ${a}.subtract(${d}) == ${b2}`);

      // Invariant: if A.since(B) is D, then B.add(D) is A, for any non-calendar largestUnit.
      const a2 = b.add(d);
      assert.equal(a2.toString(), a.toString(), `${a}.since(${b}) == ${d}, ${b}.add(${d}) == ${a2}`);
    }

    progress.tick();
  }
}

@gibson042
Copy link
Collaborator

This should really be subject to near-exhaustive testing of every date in a four-year cycle to every other, but I have not done so here.

I wrote a script to do this, and found some differences that I think must be unintentional. There seem to be some date pairs where the result is off by one year.

Thanks so much! Here is a corrected implementation of the desired algorithm:

// S is a sign function that classifies zero as positive.
const S = x => x < 0 ? -1 : 1;

export function diffNew(y1, m1, d1, y2, m2, d2, largestUnit) {
  if (largestUnit === 'year' || largestUnit === 'month') {
    let years = y2 - y1, months = m2 - m1, days = d2 - d1;
    if (years === 0 && months === 0) return { years: 0, months: 0, days };
    const sign = S(years || months || days);
    if (months !== 0 && S(months) !== sign) {
      years -= sign;
      months += 12 * sign;
    }
    assert(sign > 0 && years >= 0 && months >= 0 || sign < 0 && years <= 0 && months <= 0);
    let needsFixup = days !== 0 && S(days) !== sign;
    if (!needsFixup && sign < 0) {
      const newMonth = (m1 + months + 11) % 12 + 1;
      const newYear = newMonth <= m1 ? y1 + years : y1 + years - 1;
      if (d1 > ISODaysInMonth(newYear, newMonth)) needsFixup = true;
    }
    if (needsFixup) {
      let m = m2;
      do {
        months -= sign;
        if (sign > 0) {
          m = (m - 1) || 12;
          days += ISODaysInMonth(y2, m);
        } else {
          days -= ISODaysInMonth(y2, m);
          m = (m + 1) % 13 || 1;
        }
      } while (d1 > ISODaysInMonth(y2, m));
      if (months !== 0 && S(months) !== sign) {
        years -= sign;
        months += 12 * sign;
      }
      assert(sign > 0 && years >= 0 && months >= 0 || sign < 0 && years <= 0 && months <= 0);
    }
    if (years !== 0 && largestUnit === 'month') {
      months += years * 12;
      years = 0;
    }
    return { years, months, days };
  } else {
    assert(largestUnit === 'day' || largestUnit === 'week');
    // ...
  }
}

Running this in the difference script produces same 4222576 different 52312, and all of the differences fall into one of the following categories (not reduced):

  • ymd1 < ymd2 and d1 ≥ 29 and m2 ≤ March and the new result days equals (31 - d1) + (m2 is February ? d2 : d2 + ISODaysInMonth(y2, February)) [January {29,30,31} to {February,March} X, where the new result counts February as days].
  • ymd1 < ymd2 and d1 is 31 and the new result days equals d2 if m2 is a thirty-day month and d2 + 30 if m2 follows a thirty-day month [where the new result counts the short month as days].
  • ymd1 > ymd2 and d1 ≥ 29 and m2 is February and the new result days magnitude equals d1 + (ISODaysInMonth(y2, February) - d2) [where the new result counts the immediately following March as days].
  • ymd1 > ymd2 and d1 ≥ 29 and m2 is January and the new result days magnitude equals d1 + ISODaysInMonth(y2, February) + (31 - d2) [where the new result counts the immediately following February and March as days].
  • ymd1 > ymd2 and d1 is 31 and m2 is a thirty-day month and the new result days magnitude equals 31 + (30 - d2) [where the new result counts the short month as days].

@ptomato
Copy link
Collaborator

ptomato commented Nov 13, 2023

Thanks for the fixup. I've looked over the results as well and agree with that categorization.

It hasn't changed my opinion on this issue though. I remain in agreement with Justin, that some of the results that would be changed are problematic. I'll focus on these two which I think are good examples of the most egregious cases:

// (a)
Temporal.PlainDate.from('1970-01-29').until('1970-03-28', { largestUnit: 'months' })
  // current: 1 month and 28 days
  // proposed: 58 days
// (b)
Temporal.PlainDate.from('1970-01-31').until('1971-05-30', { largestUnit: 'years' })
  // current: 1 year, 3 months, and 30 days
  // proposed: 1 year, 2 months, and 60 days

I think these would be real headscratchers if they showed up in a UI. They seem almost certainly not what I would want as an end user.

FWIW, I did a comparison with these dates using Luxon and java.time, and unlike with the original set of dates that Anba brought to our attention, they both agree with Temporal. So apparently java.time uses some other algorithm that doesn't produce these cases. I will see what I can find out about this algorithm in the OpenJDK source code.

Meanwhile, here's my preference. Given that:

  • none of these problems show up when using the default largestUnit;
  • none of the problematic results are incorrect as such, just potentially confusing depending on what your application is, and this applies equally to alternative algorithms;
  • the results align with existing libraries in the JS ecosystem;

I'd prefer that we consider this out of scope for Temporal, keeping the door open to introduce a future proposal that adds an option with which callers can opt into the alternative algorithm.

@gibson042
Copy link
Collaborator

I continue to see results raised in the original post of this issue as very problematic. Even if Temporal doesn't switch to the fully distinct algorithm above, it would probably be better to adopt a middle ground that e.g. limits days to be within the penultimate month from start to end.

@justingrant
Copy link
Collaborator

e.g. limits days to be within the penultimate month from start to end.

@gibson042 could you give an example of what you mean?

@justingrant
Copy link
Collaborator

Great discussion! Looking forward to (hopefully!) finally resolving this question tomorrow morning.

It seems like there are three somewhat related issues on the table:

  1. Where should add constrain intermediate values?
  2. Where should until constrain intermediate values?
  3. Why don't round and until always agree?

Is this correct?

I think it might be helpful to consider each of these in sequence to structure the discussion. I'll try to respond in sequence:

1. Where should add constrain intermediate values?

My action item for the upcoming meeting was to find a clearly explainable and concise way to express the current algorithm. I actually found this by going back to @justingrant's comments in #993 (comment).

OMG that was more than 3 years ago! I had so much less gray hair then!

Reading the comment in #993 I kind of expected there to be a constrain step between adding years and months, but there isn't.

Hmm, interesting. There may be a grand unified theory that underlies the status quo: when performing math at the boundary between two specific units, constrain to the smaller unit but ignore (for now) all other smaller units. Meaning:

  • The year/month boundary is the only place where we constrain leap months in lunisolar calendars.
  • The month/week or month/day boundary (they're kinda interchangeable) is the only place where we constrain month-end dates.
  • The day/hour boundary is the only place where we disambiguate due to offset transitions.

If this grand theory is legit, then we'd need to constrain months when adding years. For example, adding 1 year to Adar 1 (the leap month in a Hebrew leap year) would result in Adar 2 (the non-leap month) in the next year. But for that particular step of the calculation, it shouldn't matter what the day is because days are irrelevant to year and month arithmetic. Days come later in the workflow.

However, in my opinion, all larger-than-time units should be added to start to compute intermediateNs together, including day-units. This would circumvent the strange behavior by being more faithful to what ZonedDateTime::add() does.

I think the same boundary-focused constraining approach above could apply here too. We should only disambiguate based on DST transitions at the day/hour boundary. We should not disambiguate due to DST when adding months but before adding days because offsets don't matter at the month/day boundary, they only matter at the day/hour boundary.

So if you think about the entire ZDT add algorithm, the workflow could be described like this:

  1. Start with this
  2. Add years
  3. Constrain to a real month (in lunisolar calendars; all others won't change anything)
  4. Add months.
  5. Constrain to a real day.
  6. Add days and weeks
  7. Constrain to a real time
  8. Add time

This does seem to be consistent in spirit with #993 (comment) even if the actual expression has evolved. What do you think?

If we agree that the above is the algorithm that we want, then it sounds like date addition already uses it. Does ZDT addition?

2. Where should until constrain intermediate values?

I admit I don't have a super-strong opinion about this one. I think @gibson042's proposal above seems reasonable. But I'm concerned that in its current form it's a bit more complicated to explain to non-compiler-authoring nerds. If we went with that proposal, I'd want to come up with a simpler way to explain it in non-math terms. I don't see this as a reason to not adopt this proposed solution... just pointing out that we'd have some work to do to simplify the docs beyond what I see above. I can help with this.

Intuitively I do find Richard's explanation of "if you can express the duration more clearly using days instead of months, do that" to make sense.

My main concern is making 100% sure that this will work, because I really, really don't want to have to make yet another normative change to until/since if we make one here.

Towards that end, here's a few tire-kicking notes.

5. the magnitude of d is such that .add({ days: d }) in condition 1 crosses at most one month boundary

Do you want to include weeks in this statement too?

6. there is no other collection y′, m′, w′, d′ of greater lexicographic order by magnitude for which the above conditions hold, unless D1.add({ years: y′, months: m′ }).add({ weeks: w′ }) is affected by the implicit overflow: "constrain" (in which case it must be the only collection of greater lexicographic order for which the above conditions hold).

@gibson042 Have you proven in code that this statement (and the ones above it) can always be true for all dates in all ICU calendars for a reasonable period of time, e.g. 1600-01-01 through 2400-01-01?

And can we validate (e.g. using @anba's original cases) that this hybrid algorithm yields "more expected" results for the specific cases that have been reported?

3. Why don't round and until always agree?

Is this the same as (1) above? If we adhere to the boundary-focused algorithm in (1), and only apply disambiguation at the day/hour boundary, then does this problem go away?

If not, then I'd like to understand the problem better.

@arshaw
Copy link
Contributor

arshaw commented Jan 25, 2024

Hi @justingrant, I'll be in the upcoming meeting but wanted to get my thoughts written out first:

1. Where should add constrain intermediate values?

If we agree that the above is the algorithm that we want, then it sounds like date addition already uses it. Does ZDT addition?

I like this algorithm and yes, PlainDateTime and ZonedDateTime already seem to do this.

2. Where should until constrain intermediate values?

I'd like to try to answer this but it's hard given that the algorithm has been described in constraint-based terms in this thread rather than imperatively. Here's my stab at explaining the ideal algorithm imperatively. If it conflicts with that's been said already, apologies! If it's in alignment with what's been said already. Maybe it will serve as the basis for a more human-readable explanation in the docs:

Given d0 and d1 (which can be either PlainDateTime or ZonedDateTime):
And given largestUnit, which might directly skipto certain steps (year=step1, month=step2, week=step3, day=step4):

  1. Add as many years as possible to d0 without surpassing d1. When adding a year value candidate, constrain to a real month/day.
  2. Add as many months as possible to d0 without surpassing d1. When adding a month value candidate, constrain to a real day.
  3. If largestUnit=week, add as many weeks as possible to d0 without surpassing d1.
  4. Add as many days as possible to d0 without surpassing d1.
  5. Constrain d0 to a real time
  6. Find the nanosecond difference between d0 and d1.
    1. If the nanosecond sign agrees with the sign from the original d0->d1, done
    2. Otherwise, repeat the algorithm but with d1 moved a day closer to d0 for steps 1-5

3. Why don't round and until always agree?

I'll try to discuss in the meeting.

@ptomato
Copy link
Collaborator

ptomato commented Jan 26, 2024

I ran the exhaustive test on the algorithm that we discussed today.

Click here for full code
import fs from 'node:fs';
import ProgressBar from 'progress';

import {
  BalanceISODate,
  BalanceISOYearMonth,
  CompareISODate,
  ConstrainISODate,
  DifferenceISODate as diffOld,
  ISODateTimePartString
} from './lib/ecmascript.mjs';

// “Surpassing” here (and in all steps below) means to take YMD all into
// account lexicographically.
function surpasses(sign, y1, m1, d1, y2, m2, d2) {
  if (sign > 0) {
    return y1 > y2 || (y1 === y2 && m1 > m2) || (y1 === y2 && m1 === m2 && d1 > d2);
  }
  return y1 < y2 || (y1 === y2 && m1 < m2) || (y1 === y2 && m1 === m2 && d1 < d2);
}

export function diffNew(y1, m1, d1, y2, m2, d2, largestUnit) {
  const sign = -CompareISODate(y1, m1, d1, y2, m2, d2);
  if (sign === 0) return { years: 0, months: 0, weeks: 0, days: 0 };

  // 1. Add (without constraining) as many years as possible to y1-m1-d1 without
  // surpassing y2-m2-d2.
  let years = 0;
  if (largestUnit === 'year') {
    while (!surpasses(sign, y1 + years, m1, d1, y2, m2, d2)) years += sign;
    years -= sign;
  }
  // 2. After we have a result, constrain to a real year/month (NOT
  // year/month/day).
  // (always a no-op here, only matters for lunisolar calendars)

  // 3. Add (without constraining) as many months as possible to y1-m1-d1
  // without surpassing y2-m2-d2. (taking all units into account).
  let months = 0;
  if (largestUnit === 'year' || largestUnit === 'month') {
    let y = y1 + years;
    let m = m1;
    while (!surpasses(sign, y, m, d1, y2, m2, d2)) {
      months += sign;
      ({ year: y, month: m } = BalanceISOYearMonth(y, m + sign));
    }
    months -= sign;
  }

  // 4. After we have a result, constrain to a real year/month/day.
  const { year: yy, month: my } = BalanceISOYearMonth(y1 + years, m1 + months);
  ({ year: y1, month: m1, day: d1 } = ConstrainISODate(yy, my, d1));

  // 5. If largestUnit=week, add as many weeks as possible to y1-m1-d1 without
  // surpassing y2-m2-d2.
  let weeks = 0;
  if (largestUnit === 'week') {
    let y = y1;
    let m = m1;
    let d = d1;
    while (!surpasses(sign, y, m, d, y2, m2, d2)) {
      weeks += sign;
      ({ year: y, month: m, day: d } = BalanceISODate(y, m, d + 7 * sign));
    }
    weeks -= sign;
  }

  // 6. Add as many days as possible to y1-m1-d1 without surpassing y2-m2-d2.
  let days = 0;
  let { year: y, month: m, day: d } = BalanceISODate(y1, m1, d1 + 7 * weeks);
  while (!surpasses(sign, y, m, d, y2, m2, d2)) {
    days += sign;
    ({ year: y, month: m, day: d } = BalanceISODate(y, m, d + sign));
  }
  days -= sign;

  return { years, months, weeks, days };
}

function fdate(y, m, d) {
  return `${y}-${ISODateTimePartString(m)}-${ISODateTimePartString(d)}`;
}

function fdur({ years, months, weeks, days }) {
  let result = '';
  if (years) result += `${years} y, `;
  if (months) result += `${months} m, `;
  if (weeks) result += `${weeks} w, `;
  result += `${days} d`;
  return result;
}

const progress = new ProgressBar(':bar :percent (:current/:total) | :etas', {
  total: 1461 ** 2,
  complete: '\u2588',
  incomplete: '\u2591',
  width: 20,
  stream: process.stdout,
  renderThrottle: 50,
  clear: true
});

const fd = fs.openSync('./exhaust.txt', 'w');

let sameCount = 0;
let differentCount = 0;

for (let i = 0; i <= 1461; i++) {
  const { year: y1, month: m1, day: d1 } = BalanceISODate(1970, 1, i + 1);
  for (let j = 0; j <= 1461; j++) {
    const { year: y2, month: m2, day: d2 } = BalanceISODate(1970, 1, j + 1);
    for (const largestUnit of ['month', 'year', 'week', 'day']) {
      const old = diffOld(y1, m1, d1, y2, m2, d2, largestUnit);
      const nu = diffNew(y1, m1, d1, y2, m2, d2, largestUnit);

      if (old.years !== nu.years || old.months !== nu.months || old.weeks !== nu.weeks || old.days !== nu.days) {
        fs.writeSync(
          fd,
          `${fdate(y1, m1, d1)}..${fdate(y2, m2, d2)} in ${largestUnit}s: old ${fdur(old)}; new ${fdur(nu)}\n`
        );
        differentCount++;
      } else {
        sameCount++;
      }
    }

    progress.tick();
  }
}

fs.closeSync(fd);

// eslint-disable-next-line no-console
console.log('same', sameCount, 'different', differentCount);

While this is an inefficient from-scratch implementation where the goal was to stay as faithful as possible to the prose steps we discussed in the meeting, I am pretty sure the results are identical to the "hybrid" algorithm that Richard expressed in an earlier comment: #2535 (comment)

The nice thing about this implementation is that you can replace the surpasses function with one that constrains and get exactly the current algorithm (albeit a very slow version of it):

function surpasses(sign, y1, m1, d1, y2, m2, d2) {
  const constrained = ConstrainISODate(y1, m1, d1);
  const cmp = -CompareISODate(constrained.year, constrained.month, constrained.day, y2, m2, d2);
  return cmp !== 0 && cmp !== sign;
}

@arshaw
Copy link
Contributor

arshaw commented Jan 26, 2024

Wow @ptomato!

I'm sure the majority of future discussion will stay in Calendar-land, but since I won't be in the meeting tomorrow, I wanted to touch on #2535 (comment), which is about when d0 is constrained to a real time. Should be easy to fix given the clarity of the new algorithm. The status quo algo constrains to a real-time in between weeks and days, which is clearly wrong. The new algo will be doing this after days, which fixes the bug.

Moving to "(3) Why don't round and until always agree", the final problem I have is expressed here: #2742 (comment)

It can be solved if Duration::round follows the same code path as PDT/ZDT::until. Dirty pseudocode:

Duration.prototype.round = function({
  relativeTo,
  smallestUnit,
  largestUnit,
  roundingIncrement,
}) {
  return relativeTo.until(
    relativeTo.add(this),
    { smallestUnit, largestUnit, roundingIncrement }
  )
}

Additional work would be required to make Duration::total consistent with Duration::round.

@ptomato
Copy link
Collaborator

ptomato commented Jan 26, 2024

My previous comment was "opinion-neutral" but I'd like to add this second comment to summarize my position.

Let's stick to Shane's nomenclature: "balanced" = current algorithm in the spec text, "exact" = algorithm that returns things like "1 month and 35 days" and is reversible, and "hybrid" = the algorithm referred to in my previous comment that was discussed in today's meeting.

  • Note that with the default options, the output of until() is already reversible in the "balanced" case, because largestUnit defaults to days (hours for ZonedDateTime). It's only if you pass largestUnit of months or years that the output becomes lossy.
  • IMO the main use case for a largestUnit of months or years is because you want a duration suitable for displaying to users. If you need to add the duration back losslessly, you'd use the default options and get a duration in days.
  • Luxon uses the "balanced" algorithm, and also Moment (although to the extent that Moment only supports single-unit durations). I don't know of any other package in the JS ecosystem that deviates from this.
  • I'm strongly negative on any "exact" algorithm unless it's opt-in only, via another option besides largestUnit. I would see that as a serious regression, that would cause "WTF" moments in perpetuity for web developers and users.
  • I don't find the argument that you can build the "balanced" algorithm in userland to be convincing. You can also build the "exact" algorithm in userland, or just use the default largestUnit.
  • I'm mildly negative on the "hybrid" algorithm. Thanks to Adam we now have a more intuitive explanation of it, so that concern is resolved. But I also don't see it as a clear improvement over the "balanced" algorithm (which we can now also explain more intuitively.) In particular, the "hybrid" algorithm doesn't address Richard's concerns.

My strong preference would be to keep the "balanced" algorithm.

It sounds like we need to generalize the "balanced" algorithm to ZonedDateTime.until/since as well, because the lack of this is causing the buggy behaviour as Adam described in the comment above. I'm in favour of fixing this bug.

I would support adding a new option in Temporal V2 to opt in to the "exact" algorithm, but not in scope of this proposal.

@arshaw
Copy link
Contributor

arshaw commented Jan 26, 2024

@ptomato, I think I'm mixing up the "hybrid" algorithm with the "balanced" algorithm. You said the "balanced" algorithm can now also be explained more intuitively. Where can I find that explanation?

@ptomato
Copy link
Collaborator

ptomato commented Jan 26, 2024

You said the "balanced" algorithm can now also be explained more intuitively. Where can I find that explanation?

@arshaw It's also in #2535 (comment) – the part where I mentioned replacing the surpasses function. Basically if you follow the "hybrid" algorithm, but constrain to an actual date when determining whether the number of years/months/weeks/days surpasses d1, you get the "balanced" algorithm.

@arshaw
Copy link
Contributor

arshaw commented Jan 26, 2024

Got it, thanks for the clarity @ptomato.

And is it possible to express the "exact" algorithm using that "hybrid" code as a base as well?

@justingrant
Copy link
Collaborator

justingrant commented Jan 26, 2024

Meeting 2024-01-25

1. Where should add constrain intermediate values?

We agreed that the alrogithm described in (add link) is both what we want and what the status quo is.

3. Constrain to a real month (in lunisolar calendars; all others won't change anything)
Need tests for this case. Otherwise we should be good.

  • Where should until constrain intermediate values?
  • Why don't round and until always agree?

I believe that we agreed (correct me if wrong) that we think this is a bug and should fix it.

2. Where should until constrain intermediate values?

We're still discussing this one. :-)

@justingrant
Copy link
Collaborator

justingrant commented Jan 26, 2024

Here's the algorithm I noted down in the meeting, based on @arshaw's and modified based on our discussion.

@ptomato Is this the "balanced" algorithm? I admit I'm not quite understanding what "hybrid" is here.

  1. Add as many years as possible to d0 without surpassing d1. When adding a year value candidate, constrain to a real year/month. Ignore days and smaller units.
  2. Add as many months as possible to d0 without surpassing d1. When adding a month value candidate, constrain to a real day. Ignore times.
  3. If largestUnit=week, add as many weeks as possible to d0 without surpassing d1.
  4. Add as many days as possible to d0 without surpassing d1.
  5. Constrain d0 to a real time
  6. Find the nanosecond difference between d0 and d1.
    1. If the nanosecond sign agrees with the sign from the original d0->d1, done
    2. Otherwise, repeat the algorithm but with d1 moved a day closer to d0 for steps 1-5

@sffc
Copy link
Collaborator

sffc commented Jan 26, 2024

I think there were 3 goals, and not all algorithms satisfy them:

  1. Reversibility
  2. Smaller units don't exceed the size of the next larger unit (no 1 month and 40 days)
  3. For a fixed d1, all dates d0 produce unique results (so that incrementing d1 for instance in a countdown timer always renders an increasing value)

How do the proposed algorithms size up on these three points?

@justingrant
Copy link
Collaborator

I think it'd be helpful to find common, real-world use cases for each algorithm, to help us pick the least bad until and since algorithm. 😄

Here's a few that I know about:

(Note these are only use cases for users who have opted into the non-default largestUnit of year, month, or week. Use cases that are OK with day (or hour for ZDT) already get all three of @sffc's benefits.)

The most popular use case for largestUnit of year, month is relative time formatting: showing a balanced duration in a UI. This could be past dates, like the one in the upper-left corner of the comment you're reading right now. Or could be future events like a little kids' app that answers the question kids love to ask: "how long it will be until my birthday?".

Another is businesses (I'm thinking of car rentals) that sell services that have volume discounts for longer chunks like months. To prepare your bill, they need to know how many months it's been since your start date, and they probably also want to know the leftover days so they can charge you for a partial month.

FWIW, neither of these cases would be OK with a result with days > 31. In the formatting case, it'd look weird and buggy. In the latter, the customer would think that the business is trying to cheat them by charging them more than one month's worth of the higher daily price.

The other use cases that I'm familiar with for until and since all are fine with the default largestUnit of day (or hour for ZDT). For example: calculating paychecks for hourly workers who clock in and clock out every day. Or calculating time-of-use billing that's based on units of days or less, like AWS, by summing the differences between an "instance start" and its corresponding "instance shutdown" event.

Are there other popular use cases for year or month largestUnit that we should consider?

@ptomato
Copy link
Collaborator

ptomato commented Jan 26, 2024

Responses to various questions 😄

And is it possible to express the "exact" algorithm using that "hybrid" code as a base as well?

@arshaw I'm not sure. I was wondering that yesterday as well, but didn't have time to work it out.

Is this the "balanced" algorithm? I admit I'm not quite understanding what "hybrid" is here.

@justingrant Yes, that's the "balanced" algorithm, which is equivalent to what we currently have in the spec. (edit: I was wrong about this. To get the "balanced" algorithm, remove "Ignore days and smaller units" from step 1.) The "hybrid" algorithm is the one we were working through examples of during the meeting yesterday, which is the same as "balanced" but without constraining each candidate.

How do the proposed algorithms size up on these three points?

@sffc

Algorithm Reversibility No excess units Unique results
Balanced No Yes No
Exact Yes No Yes
Hybrid No Yes ?

I don't know off the top of my head whether the hybrid algorithm produces unique results when you hold one of the dates constant and vary the other. I think not, but I'd have to check.

@gibson042
Copy link
Collaborator

FWIW, neither of these cases would be OK with a result with days > 31. In the formatting case, it'd look weird and buggy. In the latter, the customer would think that the business is trying to cheat them by charging them more than one month's worth of the higher daily price.

I disagree; it is definitely reasonable to render e.g. "35 days ago", and cases where that is not desired call for distinct smallestUnit rounding.

@sffc
Copy link
Collaborator

sffc commented Jan 26, 2024

Thinking out loud: d0.until(d1) with invariant: d0.add(duration).equals(d1)

d0 \ d1 1971-02-26 1971-02-27 1971-02-28 1971-03-01 1971-03-02
1970-11-27 2m 30d 3m 3m 1d 3m 2d 3m 3d
1970-11-28 2m 29d 2m 30d 3m 3m 1d 3m 2d
1970-11-29 2m 28d 2m 29d X 3m 1d 3m 2d
1970-11-30 2m 27d 2m 28d Y 3m 1d 3m 2d
1970-12-01 2m 25d 2m 26d 2m 27d 3m 3m 1d

What should X and Y be?

@sffc
Copy link
Collaborator

sffc commented Jan 26, 2024

d1.until(d0) with invariant: d1.add(duration).equals(d0)

d0 \ d1 1971-02-26 1971-02-27 1971-02-28 1971-03-01 1971-03-02
1970-11-27 -2m 29d -3m -3m 1d -3m 4d -3m 5d
1970-11-28 -2m 28d -2m 29d -3m -3m 3d -3m 4d
1970-11-29 -2m 27d -2m 28d -2m 29d -3m 2d -3m 3d
1970-11-30 -2m 26d -2m 27d -2m 28d -3m 1d -3m 2d
1970-12-01 -2m 25d -2m 26d -2m 27d -3m -3m 1d

There is no constraining / ambiguous behavior taking place here. I'll choose a different set of dates for the next post.

@sffc
Copy link
Collaborator

sffc commented Jan 26, 2024

d1.until(d0) with invariant: d1.add(duration).equals(d0)

d0 \ d1 1971-04-27 1971-04-28 1971-04-29 1971-04-30 1971-05-01
1971-02-26 -2m 1d -2m 2d A B -2m 3d
1971-02-27 -2m -2m 1d C D -2m 2d
1971-02-28 -1m 27d -2m E F -2m 1d
1971-03-01 -1m 26d -1m 27d -1m 28d -1m 29d -2m
1971-03-02 -1m 25d -1m 26d -1m 27d -1m 28d -1m 30d

In this table, there are 6 instances where constraining may need to take place during calculation.

Here are correct outcomes for A-F:

Letter d1 d0 X Y
A 1971-04-29 1971-02-26 -2m 2d -1m 31d
B 1971-04-30 1971-02-26 -2m 2d -1m 32d
C 1971-04-29 1971-02-27 -2m 1d -1m 30d
D 1971-04-30 1971-02-27 -2m 1d -1m 31d
E 1971-04-29 1971-02-28 -2m -1m 29d
F 1971-04-30 1971-02-28 -2m -1m 30d

ptomato added a commit that referenced this issue Jan 27, 2024
This adjusts the difference algorithm for Gregorian-year dates so that
when an intermediate date occurs past the end of a month, it is not
shifted to the end of that month.

Previously, in some edge cases where taking the difference in months or
years would return a number of months and zero days, we now return one
month less and 28, 29, or 30 days instead.

Example: 1970-01-29 until 1971-02-28, largestUnit years
Old result: 1 year, 1 month
New result: 1 year, 30 days

Note that largestUnit weeks and largestUnit days, the latter of which is
the default, are not affected.

TODO: Make the algorithm in the reference code faster, to help show how
it could be implemented.

Closes: #2535
ptomato added a commit that referenced this issue Jan 30, 2024
This adjusts the difference algorithm for Gregorian-year dates so that
when an intermediate date occurs past the end of a month, it is not
shifted to the end of that month.

Previously, in some edge cases where taking the difference in months or
years would return a number of months and zero days, we now return one
month less and 28, 29, or 30 days instead.

Example: 1970-01-29 until 1971-02-28, largestUnit years
Old result: 1 year, 1 month
New result: 1 year, 30 days

Note that largestUnit weeks and largestUnit days, the latter of which is
the default, are not affected.

TODO: Make the algorithm in the reference code faster, to help show how
it could be implemented.

Closes: #2535
ptomato added a commit that referenced this issue Jan 31, 2024
This adjusts the difference algorithm for Gregorian-year dates so that
when an intermediate date occurs past the end of a month, it is not
shifted to the end of that month.

Previously, in some edge cases where taking the difference in months or
years would return a number of months and zero days, we now return one
month less and 28, 29, or 30 days instead.

Example: 1970-01-29 until 1971-02-28, largestUnit years
Old result: 1 year, 1 month
New result: 1 year, 30 days

Note that largestUnit weeks and largestUnit days, the latter of which is
the default, are not affected.

Closes: #2535
@ptomato
Copy link
Collaborator

ptomato commented Feb 2, 2024

I never followed up on this issue after the 2024-01-25 and 2024-01-26 champions meetings. We decided to use the "hybrid" algorithm. I refactored the existing algorithm to be structured more like the "hybrid" algorithm, and it turns out that the difference between the two is just one line: namely, whether you constrain the date before determining whether it "surpasses" the target date. This change is made in #2759, which is to be presented at the TC39 plenary next week.

@ptomato
Copy link
Collaborator

ptomato commented Feb 7, 2024

Test262 tests in tc39/test262#4004

ptomato added a commit that referenced this issue Feb 10, 2024
This adjusts the difference algorithm for Gregorian-year dates so that
when an intermediate date occurs past the end of a month, it is not
shifted to the end of that month.

Previously, in some edge cases where taking the difference in months or
years would return a number of months and zero days, we now return one
month less and 28, 29, or 30 days instead.

Example: 1970-01-29 until 1971-02-28, largestUnit years
Old result: 1 year, 1 month
New result: 1 year, 30 days

Note that largestUnit weeks and largestUnit days, the latter of which is
the default, are not affected.

Closes: #2535
ptomato added a commit that referenced this issue Feb 10, 2024
This adjusts the difference algorithm for Gregorian-year dates so that
when an intermediate date occurs past the end of a month, it is not
shifted to the end of that month.

Previously, in some edge cases where taking the difference in months or
years would return a number of months and zero days, we now return one
month less and 28, 29, or 30 days instead.

Example: 1970-01-29 until 1971-02-28, largestUnit years
Old result: 1 year, 1 month
New result: 1 year, 30 days

Note that largestUnit weeks and largestUnit days, the latter of which is
the default, are not affected.

Closes: #2535
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
meeting-agenda normative Would be a normative change to the proposal spec-text Specification text involved
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants