[m-rev.] for review: fix a bug in calendar.duration_between/2

Julien Fischer jfischer at opturion.com
Sat Jun 27 14:16:02 AEST 2026


Hi,

Is anyone intending to review this one?   I will commit it tomorrow in
the absence
of a review.

Julien.

On Tue, 23 Jun 2026 at 11:29, Julien Fischer <jfischer at opturion.com> wrote:
>
> For review by anyone.
>
> ----------------------------
>
> Fix a bug in calendar.duration_between/2.
>
> calendar.duration_between/2 computes the difference between two date_times
> using greedy_subtract_descending/4. That predicate subtracts the fields of its
> arguments one at a time (from the smallest to the largest component), borrowing
> from the next larger field whenever the current one would otherwise go
> negative. For every field except days, the amount borrowed is a fixed quantity.
> For the days field, the amount borrowed is the month length, which is a
> variable quantity. Unfortunately, the existing code is inconsistent about which
> month's length is used. The number of days available to borrow (DaysToBorrow)
> is taken from the month adjacent to one date, while the day-of-month being
> subtracted is clamped to a *possibly different* month's length (DateAEndOfMonth
> or DateBEndOfMonth). When those two month lengths differ and the day-of-month
> falls in the gap between them, the days component can become negative while
> the months component is positive, producing a duration whose components have
> mixed signs; init_duration/7 then aborts. In other cases the same inconsistency
> leaves the days component off by the difference between the two month lengths,
> so the invariant
>
>     add_duration(duration_between(DateA, DateB), DateA, DateB)
>
> does not hold.
>
> For example, duration_between/2 aborts for 2001-01-31 and 2001-03-01.
> For 2002-11-29 and 2003-02-03 it returns P2M6D, which adds back to 2003-02-04
> rather than 2003-02-03.
>
> Replace the borrow-based computation with a direct construction of the greedy
> difference. Choose the largest whole number of months that does not overstep
> the far date -- found by a trial add_duration/3 of the year-and-month
> difference, decremented by one if it oversteps -- and compute the sub-month
> remainder as a fixed-length-unit difference using the existing
> do_fixed_duration_between/2 predicate; the month count and the remainder are
> both non-negative, so the resulting duration can no longer have mixed signs.
>
> library/calendar.m:
>     Rewrite greedy_subtract_descending/4 as described above.
>
> tests/hard_coded/calendar_test.{m,exp}:
>     Add some instances that trigger the bug.
>
> Julien.
>
> diff --git a/library/calendar.m b/library/calendar.m
> index ea1d8ff0f..67df54e28 100644
> --- a/library/calendar.m
> +++ b/library/calendar.m
> @@ -1297,68 +1297,76 @@ duration(DateA, DateB) = duration_between(DateA, DateB).
>      --->    ascending
>      ;       descending.
>
> -    % This predicate has the precondition that DateA > DateB.
> -    % OriginalOrder is the original order of the date_time arguments
> -    % (descending means that in the original call DateA < DateB, while
> -    % ascending means that in the original call DateA > DateB). This is needed
> -    % to correctly compute the days component of the resulting duration.
> -    % The calculation is different depending on the original order, because we
> -    % want the invariant:
> +    % greedy_subtract_descending(OriginalOrder, DateA, DateB, Duration):
> +    %
> +    % Duration is the non-negative difference between DateA and DateB.
> +    % It is a precondition of this predicate that DateA > DateB.
> +    % OriginalOrder is the order of the arguments in the original call to
> +    % duration_between/2:
> +    % - descending means that call had DateA < DateB, so this non-negative
> +    %   result is returned unchanged;
> +    % - ascending means it had DateA > DateB, so the caller negates this
> +    %   result.
> +    % The two cases anchor the whole-month count at opposite ends.
> +    % This is required so that the invariant
>      %   add_duration(duration_between(DateA, DateB), DateA, DateB)
> -    % to hold, and in the case where DateA > DateB, Duration will be negative.
> +    % holds for the original call for both orders.
> +    %
> +    % The greedy difference is the largest whole number of months Months such
> +    % that
> +    % - DateB + Months months =< DateA, in the descending case; or
> +    % - DateA - Months months >= DateB, in the ascending case
> +    % The remaining difference will span less than a month and is measured in
> +    % days and smaller units only.
> +    % Because add_duration/3 applies a duration's months component before its
> +    % days component, this is precisely the duration that add_duration/3
> +    % inverts, so the invariant holds by construction.
> +    % Months is found using a trial duration addition of the year-and-month
> +    % difference, decremented by one if that oversteps DateA (descending) or
> +    % DateB (ascending) because the days and time of day are not yet accounted
> +    % for. The sub-month remainder is found using do_fixed_duration_between/2.
> +    % Both the month count and the remainder will be non-negative.
> +    % This satisfies the requirement that the components of a duration do not
> +    % have mixed signs.
>      %
>  :- pred greedy_subtract_descending(order::in, date_time::in,
>      date_time::in, duration::out) is det.
>
>  greedy_subtract_descending(OriginalOrder, DateA, DateB, Duration) :-
> -    some [!Borrow] (
> -        MicroSecondA = DateA ^ dt_microsecond,
> -        MicroSecondB = DateB ^ dt_microsecond,
> -        subtract_ints_with_borrow(microseconds_per_second, MicroSecondA,
> -            MicroSecondB, MicroSeconds, !:Borrow),
> -        SecondA = DateA ^ dt_second - !.Borrow,
> -        SecondB = DateB ^ dt_second,
> -        subtract_ints_with_borrow(60, SecondA, SecondB, Seconds, !:Borrow),
> -        MinuteA = DateA ^ dt_minute - !.Borrow,
> -        MinuteB = DateB ^ dt_minute,
> -        subtract_ints_with_borrow(60, MinuteA, MinuteB, Minutes, !:Borrow),
> -        HourA = DateA ^ dt_hour - !.Borrow,
> -        HourB = DateB ^ dt_hour,
> -        subtract_ints_with_borrow(24, HourA, HourB, Hours, !:Borrow),
> -        (
> -            OriginalOrder = descending,
> -            add_duration(duration(-1, 0, 0, 0), DateA, DateAMinus1Month),
> -            DaysToBorrow = max_day_in_month_for(DateAMinus1Month ^ dt_year,
> -                DateAMinus1Month ^ dt_month),
> -            DateAEndOfMonth = max_day_in_month_for(DateA ^ dt_year,
> -                DateA ^ dt_month),
> -            DayA = DateA ^ dt_day - !.Borrow,
> -            DayB = int.min(DateB ^ dt_day, DateAEndOfMonth)
> -        ;
> -            OriginalOrder = ascending,
> -            DaysToBorrow = max_day_in_month_for(DateB ^ dt_year,
> -                DateB ^ dt_month),
> -            DateBEndOfMonth = max_day_in_month_for(DateB ^ dt_year,
> -                DateB ^ dt_month),
> -            DayA = int.min(DateA ^ dt_day - !.Borrow, DateBEndOfMonth),
> -            DayB = DateB ^ dt_day
> +    MonthsUpperBound = 12 * (DateA ^ dt_year - DateB ^ dt_year)
> +        + (DateA ^ dt_month - DateB ^ dt_month),
> +    (
> +        OriginalOrder = descending,
> +        % Anchor the months at the earlier date and step forwards, calculating
> +        % the largest whole number of months that does not overshoot DateA.
> +        add_duration(init_duration(0, MonthsUpperBound, 0, 0, 0, 0, 0),
> +            DateB, Candidate),
> +        ( if compare((>), Candidate, DateA) then
> +            Months = MonthsUpperBound - 1
> +        else
> +            Months = MonthsUpperBound
>          ),
> -        subtract_ints_with_borrow(DaysToBorrow, DayA, DayB, Days, !:Borrow),
> -        MonthA = DateA ^ dt_month - !.Borrow,
> -        MonthB = DateB ^ dt_month,
> -        subtract_ints_with_borrow(12, MonthA, MonthB, Months, !:Borrow),
> -        YearA = DateA ^ dt_year - !.Borrow,
> -        YearB = DateB ^ dt_year,
> -        ( if YearA >= YearB then
> -            Years = YearA - YearB
> +        add_duration(init_duration(0, Months, 0, 0, 0, 0, 0), DateB, Landing),
> +        % Landing =< DateA, and Landing differs from DateA by less than a
> +        % month, so the remainder has no month component.
> +        Remainder = do_fixed_duration_between(Landing, DateA)
> +    ;
> +        OriginalOrder = ascending,
> +        % Anchor the months at the later date and step backwards, calculating
> +        % the largest whole number of months that does not undershoot DateB.
> +        add_duration(init_duration(0, -MonthsUpperBound, 0, 0, 0, 0, 0),
> +            DateA, Candidate),
> +        ( if compare((<), Candidate, DateB) then
> +            Months = MonthsUpperBound - 1
>          else
> -            % If this happens, then DateA < DateB, which violates
> -            % a precondition of this predicate.
> -            unexpected($pred, "left over years")
> +            Months = MonthsUpperBound
>          ),
> -        Duration = init_duration(Years, Months, Days, Hours, Minutes, Seconds,
> -            MicroSeconds)
> -    ).
> +        add_duration(init_duration(0, -Months, 0, 0, 0, 0, 0), DateA, Landing),
> +        % Landing >= DateB, differing by less than a month.
> +        Remainder = do_fixed_duration_between(DateB, Landing)
> +    ),
> +    Remainder = duration(_, RemDays, RemSeconds, RemMicroSeconds),
> +    Duration = duration(Months, RemDays, RemSeconds, RemMicroSeconds).
>
>      % subtract_ints_with_borrow(BorrowAmount, Val1, Val2, Val, Borrow):
>      %
> diff --git a/tests/hard_coded/calendar_test.exp
> b/tests/hard_coded/calendar_test.exp
> index 1dfda1725..5ed50ff53 100644
> --- a/tests/hard_coded/calendar_test.exp
> +++ b/tests/hard_coded/calendar_test.exp
> @@ -109,6 +109,36 @@ G: 2001-04-10 06:00:00 -> 2001-02-20 18:30:00 =
> -P1M17DT11H30M checked ok
>  D: 2001-02-20 18:30:00 -> 2001-04-10 06:00:00 = P48DT11H30M checked ok
>  D: 2001-04-10 06:00:00 -> 2001-02-20 18:30:00 = -P48DT11H30M checked ok
>
> +G: 2001-01-31 00:00:00 -> 2001-03-01 00:00:00 = P1M1D checked ok
> +G: 2001-03-01 00:00:00 -> 2001-01-31 00:00:00 = -P1M1D checked ok
> +D: 2001-01-31 00:00:00 -> 2001-03-01 00:00:00 = P29D checked ok
> +D: 2001-03-01 00:00:00 -> 2001-01-31 00:00:00 = -P29D checked ok
> +
> +G: 2001-03-30 00:00:00 -> 2001-01-31 00:00:00 = -P1M28D checked ok
> +G: 2001-01-31 00:00:00 -> 2001-03-30 00:00:00 = P1M30D checked ok
> +D: 2001-03-30 00:00:00 -> 2001-01-31 00:00:00 = -P58D checked ok
> +D: 2001-01-31 00:00:00 -> 2001-03-30 00:00:00 = P58D checked ok
> +
> +G: 2002-11-29 00:00:00 -> 2003-02-03 00:00:00 = P2M5D checked ok
> +G: 2003-02-03 00:00:00 -> 2002-11-29 00:00:00 = -P2M4D checked ok
> +D: 2002-11-29 00:00:00 -> 2003-02-03 00:00:00 = P66D checked ok
> +D: 2003-02-03 00:00:00 -> 2002-11-29 00:00:00 = -P66D checked ok
> +
> +G: 2001-03-31 00:00:00 -> 2001-05-01 00:00:00 = P1M1D checked ok
> +G: 2001-05-01 00:00:00 -> 2001-03-31 00:00:00 = -P1M1D checked ok
> +D: 2001-03-31 00:00:00 -> 2001-05-01 00:00:00 = P31D checked ok
> +D: 2001-05-01 00:00:00 -> 2001-03-31 00:00:00 = -P31D checked ok
> +
> +G: 2000-12-31 00:00:00 -> 2001-02-01 00:00:00 = P1M1D checked ok
> +G: 2001-02-01 00:00:00 -> 2000-12-31 00:00:00 = -P1M1D checked ok
> +D: 2000-12-31 00:00:00 -> 2001-02-01 00:00:00 = P32D checked ok
> +D: 2001-02-01 00:00:00 -> 2000-12-31 00:00:00 = -P32D checked ok
> +
> +G: 2001-01-31 18:00:00 -> 2001-03-01 06:00:00 = P1MT12H checked ok
> +G: 2001-03-01 06:00:00 -> 2001-01-31 18:00:00 = -P1MT12H checked ok
> +D: 2001-01-31 18:00:00 -> 2001-03-01 06:00:00 = P28DT12H checked ok
> +D: 2001-03-01 06:00:00 -> 2001-01-31 18:00:00 = -P28DT12H checked ok
> +
>
>  Day of the week:
>  2008-01-15 23:59:00 : tuesday
> diff --git a/tests/hard_coded/calendar_test.m b/tests/hard_coded/calendar_test.m
> index a4b423d77..1a350d528 100644
> --- a/tests/hard_coded/calendar_test.m
> +++ b/tests/hard_coded/calendar_test.m
> @@ -67,6 +67,12 @@ main(!IO) :-
>      test_diff("2000-01-20 00:00:00", "2000-03-10 00:00:00", !IO),
>      test_diff("2001-03-25 00:00:00", "2001-04-10 00:00:00", !IO),
>      test_diff("2001-02-20 18:30:00", "2001-04-10 06:00:00", !IO),
> +    test_diff("2001-01-31 00:00:00", "2001-03-01 00:00:00", !IO),
> +    test_diff("2001-03-30 00:00:00", "2001-01-31 00:00:00", !IO),
> +    test_diff("2002-11-29 00:00:00", "2003-02-03 00:00:00", !IO),
> +    test_diff("2001-03-31 00:00:00", "2001-05-01 00:00:00", !IO),
> +    test_diff("2000-12-31 00:00:00", "2001-02-01 00:00:00", !IO),
> +    test_diff("2001-01-31 18:00:00", "2001-03-01 06:00:00", !IO),
>      io.nl(!IO),
>      io.write_string("Day of the week:\n", !IO),
>      test_day_of_week("2008-01-15 23:59:00", !IO),


More information about the reviews mailing list