[m-rev.] for review: improve the documentation of the calendar module

Zoltan Somogyi zoltan.somogyi at runbox.com
Fri Mar 13 18:50:31 AEDT 2026



On Fri, 13 Mar 2026 17:46:20 +1100, Julien Fischer <jfischer at opturion.com> wrote:
> diff --git a/library/calendar.m b/library/calendar.m
> index 192d8f02d..9dd627ccd 100644
> --- a/library/calendar.m
> +++ b/library/calendar.m
> @@ -10,15 +10,15 @@
>  % Main authors: maclarty
>  % Stability: high.
>  %
> -% Proleptic Gregorian calendar utilities.
> +% Proleptic Gregorian calendar utilities with date-time representation to
> +% microsecond resolution.
>  % The Gregorian calendar is the calendar that is currently used by most of
>  % the world. In this calendar, a year is a leap year if it is divisible by
> -% 4, but not divisible by 100. The only exception is if the year is divisible
> -% by 400, in which case it is a leap year. For example, 1900 is not a leap
> -% year, while 2000 is. The proleptic Gregorian calendar is an extension of the
> -% Gregorian calendar backward in time to before it was first introduced in
> -% 1582.
> +% 400, or if it is divisible by 4 but not by 100. For example, 2000 and 2024
> +% were leap years, while 1900 and 2023 were not. The proleptic Gregorian
> +% calendar is an extension of the Gregorian calendar backward in time to
> +% before it was first introduced in October 1582.

I don't like either version much; will propose changes after you commit.

> @@ -120,12 +122,24 @@
>  %---------------------%
> 
>      % init_date(Year, Month, Day, Hour, Minute, Second, MicroSecond, Date):
> -    % Initialize a new date. Fails if the given date is invalid.
> +    %
> +    % Initialise a new date from the given components. Fails if any of the
> +    % following conditions are not met:
> +    %
> +    %   - Day is in the range 1..N, where N is the number of days in Month
> +    %     of Year
> +    %   - Hour is in the range 0..23
> +    %   - Minute is in the range 0..59
> +    %   - Second is in the range 0..61

Add "(to account for up to two leap seconds per year)".

> +    %   - MicroSecond is in the range 0..999999
> +    %
> +    % No validation is performed on Year; years with more or fewer than
> +    % four digits are accepted.

I don't think this says anything that "This predicate accepts all X values for Year"
does not.

In other places, you mention that operations can *generate* negative values
for Year, so you must specify whether you *accept* them. You can do this by
using either "positive", "non-negative", or "" as the value of X above.

> @@ -138,41 +152,62 @@
> 
>  %---------------------%
> 
> -    % Convert a string of the form "YYYY-MM-DD HH:MM:SS.mmmmmm" to a date.
> -    % The microseconds component (.mmmmmm) is optional.
> +    % Convert a string of the form "[-]YYYY-MM-DD HH:MM:SS.mmmmmm" to a date.
> +    % The year must have at least four digits; years with more than four digits
> +    % are accepted. The microseconds component (.mmmmmm) is optional. If
> +    % present, it may have between one and six digits.
> +    %
> +    % Fail if the string does not conform to the above format, or if any
> +    % date or time component is outside its valid range.

What is the justification for restricting to 4+ digit years? And what is the justification
for the difference between date_from_string and init_date?

>  :- pred date_from_string(string::in, date::out) is semidet.
> 
> -    % Same as above, but throws an exception if the string is not a valid date.
> +    % As above, but throw an exception if the string is not a valid date.
>      %
>  :- func det_date_from_string(string) = date.
> 
> -    % Convert a date to a string of the form "YYYY-MM-DD HH:MM:SS.mmmmmm".
> -    % If the microseconds component of the date is zero, then the
> -    % ".mmmmmm" part is omitted.
> +    % Convert a date to a string of the form "[-]YYYY-MM-DD HH:MM:SS.mmmmmm".
> +    % If the microseconds component of the date is zero, then the ".mmmmmm"
> +    % part is omitted.

... then omit the ...

> +    % seconds and microseconds.
> +    %
> +    % A duration may be positive (moving a date forward in time) or negative
> +    % (moving a date backward in time). All non-zero components must share the
> +    % same sign; a duration with a mix of positive and negative components
> +    % cannot be constructed.
> +    %
> +    % Years and months are context-dependent units whose length in absolute
> +    % time varies with the dates they are applied to. A year is treated as
> +    % 12 months, and a month is 28-31 days depending on the calendar month
> +    % and year. In contrast, days and smaller units are fixed-length:
> +    % 1 day = 86,400 seconds (leap seconds are ignored; see below).
> +    %
> +    % When adding a year or month component causes the day to fall outside
> +    % the target month, it is clamped to the last day of that month.
> +    % This applies equally to positive and negative durations. For example:
> +    %
> +    %   - Adding  1 month  to January 31  gives February 28 (29 in a leap year)
> +    %   - Adding  1 year   to February 29, 2020 gives February 28, 2021
> +    %   - Adding -1 month  to March 31    gives February 28 (29 in a leap year)
> +    %   - Adding -1 year   to February 29, 2020 gives February 28, 2019
> +    %
> +    % Note on leap seconds: although individual dates can represent times
> +    % with leap seconds (seconds 60-61), durations ignore them. A day is
> +    % always treated as exactly 86,400 seconds, even though UTC days
> +    % containing leap seconds are 86,401 seconds long.
> +    %
> +    % Durations are stored internally using four components only: months, days,
> +    % seconds and microseconds. When a duration is constructed by
> +    % init_duration/7, the seven input components are normalised into these
> +    % four.
> +    %
> +    %   - Years are converted to months and added to the months component
> +    %   - Microseconds are divided into whole seconds (which are carried over)
> +    %     and a microseconds remainder.
> +    %   - Hours, minutes, seconds, and any carried seconds are combined into a
> +    %     total number of seconds.
> +    %   - Whole days in that seconds total are carried into the days component,
> +    %     and the remainder becomes the seconds component.
> +    %
> +    % Days are never folded into months (a month does not have a fixed number
> +    % of days), and months are never folded into years during normalisation.
> +    % As a result, the duration component access functions may return values
> +    % that differ from the init_duration/7 arguments. For example:
> +    %
> +    %   - init_duration(1, 18, 0, 0, 0, 0, 0) => years = 2, months = 6
> +    %   - init_duration(0, 0, 0, 25, 0, 0, 0) => days = 1, hours = 1
>      %
>  :- type duration.

That is a very good explanation.

> @@ -226,7 +278,37 @@
>  :- type seconds == int.
>  :- type microseconds == int.
> 
> -    % Functions to retrieve duration components.
> +    % Functions to retrieve the components of a duration.
> +    %
> +    % Years and months are derived from the single combined months total in the
> +    % duration:
> +    %
> +    %   years/1  = total months // 12

The / in "years/1" is doing a completely different job from the ones in
"months // 12". I would make this "the years function returns ....".

> +    %   months/1 = total months rem 12
> +    %
> +    % Hours, minutes and seconds are derived from the single combined seconds
> +    % total in the duration:
> +    %
> +    %   hours/1   = total seconds // 3600
> +    %   minutes/1 = total seconds rem 3600 // 60
> +    %   seconds/1 = total seconds rem 60
> +    %   microseconds/1 = total microseconds
> +    %
> +    % For positive durations:
> +    %   months/1 returns a value in 0..11
> +    %   days/1 returns a non-negative value

You *must* explicitly mention the absence of an upper limit,
if indeed there is none.

> +    %   hours/1 returns a value in 0..23
> +    %   minutes/1 returns a value in 0..59
> +    %   seconds/1 returns a value in 0..59
> +    %   microseconds/1 returns a value in 0..999999

The last three are immediate consequences of the above block
(the one with //s and rems), and the first would be also, if you
specify that any hours above 24 get carried to the days part.

> +    % For negative durations:
> +    %   months/1 returns a value in -11..0
> +    %   days/1 returns a non-positive value
> +    %   hours/1 returns a value in -23..0
> +    %   minutes/1 returns a value in -59..0
> +    %   seconds/1 returns a value in -59..0
> +    %   microseconds/1 returns a value in -999999..0

Mirror images of the my comments above.

>  :- func years(duration) = years.
>  :- func months(duration) = months.
> @@ -239,8 +321,22 @@
>      % init_duration(Years, Months, Days, Hours, Minutes, Seconds,
>      %   MicroSeconds) = Duration:
>      %
> -    % Create a new duration. All of the components should either be
> -    % non-negative or non-positive (they can all be zero).
> +    % Create a new duration from the given components.
> +    % All non-zero components must have the same sign (they must be entirely
> +    % positive or entirely negative). An exception is thrown if two or more
> +    % non-zero components have different signs.

This function throws an exception if ...

Also, the "or more" does not help here; there is no third sign available.

> +    % For example, all of the following are valid:
> +    %
> +    %  - init_duration(1, 2, 15, 0, 0, 0, 0)    (all positive or zero)
> +    %  - init_duration(0, 0, -3, -12, 0, 0, 0)  (all negative or zero)
> +    %  - init_duration(0, 0, 0, 0, 0, 0, 0)     (all zero)
> +    %
> +    % But the following contain non-zero components with mixed signs and will
> +    % throw an exception:
> +    %
> +    %  - init_duration(0, 1, -5, 0, 0, 0, 0)
> +    %  - init_duration(0, 0, 0, 2, -30, 0, 0)
>      %
>      % If you need a fixed absolute time period that is independent of calendar
>      % context, then use only the days, hours, minutes, seconds and microseconds
> @@ -267,30 +363,37 @@
> 
>      % Parse a duration string.
>      %
> -    % The string should be of the form "PnYnMnDTnHnMnS" where each "n" is a
> +    % The string should be of the form "[-]PnYnMnDTnHnMnS" where each "n" is a

should HAVE the form

>      % non-negative integer representing the number of years (Y), months (M),
>      % days (D), hours (H), minutes (M) or seconds (S).

I would say explicitly that the units follow the numbers, not precede them.

> @@ -301,78 +404,73 @@
> 
>  %---------------------%
> 
> -    % Add a duration to a date.
> +    % add_duration(Duration, Date0, Date):
>      %
> -    % First the years and months are added to the date.
> -    % If this causes the day to be out of range (e.g. April 31), then it is
> -    % decreased until it is in range (e.g. April 30). Next the remaining
> -    % days, hours, minutes and seconds components are added. These could
> -    % in turn cause the month and year components of the date to change again.
> +    % Add Duration to Date0 to yield Date, clamping the day to the end of the
> +    % month if the month or year component of the duration causes it to fall
> +    % out of range.
> +    % (See the documentation of the type duration/0 for the clamping rules.)
>      %
>  :- pred add_duration(duration::in, date::in, date::out) is det.
> 
> -    % This predicate implements a partial order relation on durations.
> -    % DurationA is less than or equal to DurationB if-and-only-if for all
> -    % of the dates listed below, adding DurationA to the date results in a date
> -    % less than or equal to the date obtained by adding DurationB.
> +    % duration_leq(DurationA, DurationB):
> +    %
> +    % Succeed if-and-only-if DurationA is less than or equal to DurationB.
> +    % This relation is a partial order: some pairs of durations are
> +    % incomparable, because their relative size depends on the date they
> +    % are applied to (e.g. 1 month vs. 30 days may compare differently
> +    % in different months).
> +    %
> +    % DurationA is considered less than or equal to DurationB if adding
> +    % DurationA to each of the following dates yields a result no later
> +    % than adding DurationB to the same date. These dates are chosen to
> +    % exercise leap-year and variable month-length boundaries:
>      %
>      %    1696-09-01 00:00:00
>      %    1697-02-01 00:00:00
>      %    1903-03-01 00:00:00
>      %    1903-07-01 00:00:00
>      %
> -    % There is only a partial order on durations, because some durations
> -    % cannot be said to be less than, equal to or greater than another duration
> -    % (e.g. 1 month vs. 30 days).
> +    % The predicate fails if DurationA is greater than DurationB for any
> +    % of the above dates, including the case where the two durations are
> +    % incomparable (i.e. DurationA yields an earlier result for some test
> +    % dates but a later result for others).

They exercise leap-year and month-length boundaries, but do they exercise
all possible combinations of those boundaries? The fact that there are four dates
seems promising, since there leap/nonleap and 28/29 days for feb also has
four combinations, but without an explicit promise, and preferably a correctness
argument, I would not want to rely on just that fact ...

>  :- pred duration_leq(duration::in, duration::in) is semidet.
> 
> -    % Get the difference between local and UTC time as a duration.
> +    % local_time_offset(Offset, !IO):
>      %
> -    % local_time_offset(TZ, !IO) is equivalent to:
> -    %   current_local_time(Local, !IO),
> -    %   current_utc_time(UTC, !IO),
> -    %   TZ = duration(UTC, Local)
> -    % except that it is as if the calls to current_utc_time and
> -    % current_local_time occurred at the same instant.
> +    % Offset is the difference between local and UTC time, that is, the
> +    % value of duration(UTC, Local), where Local and UTC are the local and UTC
> +    % representations of the same point in time.
>      %
> -    % To convert UTC time to local time, add the result of local_time_offset/3
> -    % to UTC (using add_duration/3). To compute UTC given the local time,
> -    % first negate the result of local_time_offset/3 (using negate/1) and then
> -    % add it to the local time.
> +    % To convert UTC time to local time, add Offset to UTC using
> +    % add_duration/3. To convert local time to UTC, negate Offset using
> +    % negate/1 and add the result to the local time.

You probably want to explicitly mention that Offset will vary with Daylight
Savings time.

> +    % duration(DateA, DateB) = Duration:
> +    %
> +    % Return the duration from DateA to DateB using a greedy algorithm that
> +    % maximises each component in this order: years, months, days, hours,
> +    % minutes, seconds, microseconds. The result is positive if DateB is after
> +    % DateA and negative if DateB is before DateA. Leap seconds are ignored.
> +    %
> +    % The dates should be in the same timezone and daylight savings phase;
> +    % to find the duration between dates in different timezones or daylight
> +    % savings phases, first convert them to UTC.

convert them BOTH

The rest is fine.

Zoltan.


More information about the reviews mailing list