Skip to content

Compute Datetime.diff and Duration in Long so spans past 68 years hold - #29

Merged
hellerve merged 2 commits into
masterfrom
claude/duration-long
Aug 31, 2026
Merged

Compute Datetime.diff and Duration in Long so spans past 68 years hold#29
hellerve merged 2 commits into
masterfrom
claude/duration-long

Conversation

@carpentry-agent

@carpentry-agent carpentry-agent Bot commented Aug 29, 2026

Copy link
Copy Markdown

The follow-up #27 flagged in its own body. Datetime.diff ends in
(+ (* (- ord-a ord-b) DAY) (- time-a time-b)) with DAY at 86400, so any span
over 2^31/86400 = 24855 days wraps, and Duration's seconds- field was an
Int, so between inherited the wrap and the constructors overflowed on their
own.

Measured on master before the change:

diff  2100-01-01 - 2000-01-01     -> -1139207296     want  3155760000
diff  2000-01-01 - 2100-01-01     ->  1139207296     want -3155760000
weeks 3551          (to-seconds)  -> -2147322496     want  2147644800
days  24856         (to-seconds)  -> -2147408896     want  2147558400
weeks 3551          (to-days)     -> -24853          want  24857
str   (days 36524)               -> "-13186d 6h 28m 16s"  want "36524d"
>     (days 30000) (days 1)       ->  false          want  true
pos?  (days 30000)                ->  false          want  true
neg?  (days 30000)                ->  true           want  false
add   2000-01-01 (days 36524)     ->  1963-11-24     want  2099-12-31

Note 2000-01-01 to 2100-01-01 is 36525 days, not 36524 — 2000 is a leap year —
so the century is 3155760000 seconds.

What changed

Datetime.diff returns a Long and Duration holds a Long. The widening is
at the multiplication, as in #27: DAY/HOUR/MINUTE/SECOND stay Int,
since the time-of-day part is bounded by 86399 and only the day count can
overflow. plus/minus/negate/scale/mul/to-*/=/</>/zero?/
pos?/neg?/str moved to Long arithmetic. In str only the day component
can exceed an Int; hours, minutes and seconds are bounded by their units and
stay Int, so only the %dd directive changed.

The constructors still take an Int count. (Duration.days 2) is what the
module's own docstring writes, and 2 is an Int literal, so seconds,
minutes, hours, days and weeks keep their Int parameter and widen at
the multiply — (Duration.days 30000) stops wrapping without anyone editing
their code. scale/mul keep their Int factor for the same reason. No
Long-taking variants added; nothing suggests a caller needs a count that an
Int cannot hold.

add/sub split the total rather than widening add-seconds. They now
divide the Long total into whole days and a remainder under a day, shift the
date by the days through the ordinal, and pass only the remainder — always
within ±86399 — to Datetime.add-seconds. Widening add-seconds to Long
instead would have been both more invasive and no more correct:

  • it is public and its callers pass Ints — http/http.carp:177 is
    (Datetime.add-seconds &(Datetime.now) i), with i an Int from a
    Max-Age parse, so it would have needed a Long.from-int at the call site;
  • its body is built on the Int div-/mod- helpers and the signed-overflow
    fix from Fix signed overflow in Datetime.add-seconds for large offsets #19, all of which would have had to be reworked;
  • and it would still have had to narrow the day count, because a Datetime
    names its date by an Int ordinal. The narrowing does not go away, it just
    moves somewhere hairier.

Nothing is silently truncated at that boundary: the ordinal sum is computed in
Long and clamped to the Int ordinal range, so a day count past what an
ordinal can name saturates instead of wrapping into a plausible date. Dates
before 0001-01-01 remain outside from-ordinal's domain — 0001-01-01 minus
a day is 0001-00-00 on master today — which this change does not address.

I did not add a public Datetime.add-days, which would have made Duration.add
a one-liner; the private helper keeps the change to the bug.

Blast radius

Grepping all 47 clones under carpentry/ for Datetime.diff and Duration.:
there are no hits outside this repo's own source, tests and gendocs.carp.
Both are used exclusively within time. Datetime.add-seconds, which
this change deliberately leaves alone, has one external caller
(http/http.carp:177) and it is unaffected.

Tests

17 new assertions, all of which fail on the Int arithmetic:

  • a century-long diff in both signs
  • weeks 3551 and days 24856, one step past each constructor's wrap point
  • between and to-days across a century
  • >, <, pos?, neg? for a delta past the boundary; = on two deltas that
    collide when truncated to 32 bits (days 49710 vs seconds -23296); zero?
    on a sum of exactly 2^32 seconds
  • str for a positive and a negative multi-decade delta
  • add/sub of a century-long delta, including one that keeps the time of day

Expected values over 2^31 are assembled from parts ((* 36525l 86400l))
rather than written as wide literals, following #27 — an oversized Long
literal is folded on a 32-bit host, which is where I ran this.

The suite is 335 passing, 0 failing. Putting the Int arithmetic back under the
Long signatures fails exactly those 17 and no others, so they pin the widening
rather than the types. carp-fmt --check and angler are clean, and
docs/Datetime.html and docs/Duration.html are regenerated — the only diff is
the five changed signatures.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

carpentry-heartbeat[bot] and others added 2 commits August 29, 2026 08:20
diff multiplied the ordinal difference by DAY in 32-bit Int, so any span
over 2^31/86400 = 24855 days wrapped: 2100-01-01 minus 2000-01-01 (36525
days, 3155760000 seconds) came out as -1139207296, and the reversed pair
came out positive. Duration's seconds- field was an Int, so between
inherited that and the constructors overflowed on their own -- weeks 3551
gave -2147322496 instead of 2147644800, days 24856 gave -2147408896.
Every derived operation followed: to-days of weeks 3551 was -24853, str
of days 36524 rendered "-13186d 6h 28m 16s", pos? was false and neg? true
for days 30000, and add of days 36524 to 2000-01-01 landed on 1963-11-24.

diff now returns a Long and Duration holds a Long, with the widening at
the multiplication as in 30f21fd; DAY/HOUR/MINUTE/SECOND stay Int, since
only the day count can overflow.

The constructors keep taking an Int count, so (Duration.days 2) still
compiles, and widen at the multiply. scale likewise keeps its Int factor.

add and sub no longer hand a whole Duration to Datetime.add-seconds,
which takes an Int: they split the Long total into whole days and a
remainder under a day, shift the date by the days through the ordinal,
and pass only the remainder to add-seconds. Widening add-seconds instead
would have broken its Int callers -- http/http.carp:177 is one -- and
would still have had to narrow the day count for the Int ordinal. The
ordinal sum is clamped rather than truncated, so a day count past the
range an ordinal can name cannot wrap into a plausible date.

Seventeen new assertions cover a century-long diff in both signs, weeks
and days past their wrap points, between and to-days across a century,
each comparison operator over the boundary, two deltas that collide when
truncated to 32 bits, a sum of exactly 2^32 seconds, str for a positive
and a negative multi-decade delta, and add/sub of a century-long delta
including the time of day. All seventeen fail with the Int arithmetic
restored under the Long signatures.
@hellerve
hellerve merged commit 1e05133 into master Aug 31, 2026
2 checks passed
@hellerve
hellerve deleted the claude/duration-long branch August 31, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant