From a644c44824a42afaded930c52986ea67b2a55f34 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 29 Aug 2026 08:20:06 +0200 Subject: [PATCH 1/2] Compute Datetime.diff and Duration in Long so spans past 68 years hold 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. --- docs/Datetime.html | 2 +- docs/Duration.html | 8 ++-- test/time.carp | 110 ++++++++++++++++++++++++++++++++++++++------- time.carp | 72 ++++++++++++++++++----------- 4 files changed, 145 insertions(+), 47 deletions(-) diff --git a/docs/Datetime.html b/docs/Datetime.html index 276dcb2..d0adf0d 100644 --- a/docs/Datetime.html +++ b/docs/Datetime.html @@ -324,7 +324,7 @@

defn

- (Fn [(Ref Datetime a), (Ref Datetime b)] Int) + (Fn [(Ref Datetime a), (Ref Datetime b)] Long)

                         (diff a b)
diff --git a/docs/Duration.html b/docs/Duration.html
index 76e043d..dfb88e8 100644
--- a/docs/Duration.html
+++ b/docs/Duration.html
@@ -490,7 +490,7 @@ 

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-days d)
@@ -511,7 +511,7 @@ 

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-hours d)
@@ -532,7 +532,7 @@ 

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-minutes d)
@@ -553,7 +553,7 @@ 

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-seconds d)
diff --git a/test/time.carp b/test/time.carp
index 262415d..3bc0fb9 100644
--- a/test/time.carp
+++ b/test/time.carp
@@ -1692,19 +1692,19 @@
     "subtract 3661 seconds (1h1m1s) from 00:00:00")
 
   (assert-equal test
-    0
+    0l
     (Datetime.diff &(Datetime.date 2024 3 15) &(Datetime.date 2024 3 15))
     "diff: same date is 0")
   (assert-equal test
-    86400
+    86400l
     (Datetime.diff &(Datetime.date 2024 3 16) &(Datetime.date 2024 3 15))
     "diff: one day apart is 86400")
   (assert-equal test
-    -86400
+    -86400l
     (Datetime.diff &(Datetime.date 2024 3 15) &(Datetime.date 2024 3 16))
     "diff: reversed order is negative")
   (assert-equal test
-    3600
+    3600l
     (Datetime.diff
       &(Datetime.init 2024
                       3
@@ -1724,7 +1724,7 @@
                       (Maybe.Nothing)))
     "diff: one hour apart is 3600")
   (assert-equal test
-    90061
+    90061l
     (Datetime.diff
       &(Datetime.init 2024
                       3
@@ -1744,13 +1744,21 @@
                       (Maybe.Nothing)))
     "diff: 1 day 1 hour 1 minute 1 second = 90061")
   (assert-equal test
-    (* 365 86400)
+    (* 365l 86400l)
     (Datetime.diff &(Datetime.date 2023 1 1) &(Datetime.date 2022 1 1))
     "diff: non-leap year is 365 days")
   (assert-equal test
-    (* 366 86400)
+    (* 366l 86400l)
     (Datetime.diff &(Datetime.date 2025 1 1) &(Datetime.date 2024 1 1))
     "diff: leap year 2024 has 366 days")
+  (assert-equal test
+    (* 36525l 86400l)
+    (Datetime.diff &(Datetime.date 2100 1 1) &(Datetime.date 2000 1 1))
+    "diff: a century of seconds does not wrap")
+  (assert-equal test
+    (* -36525l 86400l)
+    (Datetime.diff &(Datetime.date 2000 1 1) &(Datetime.date 2100 1 1))
+    "diff: a negative century of seconds does not wrap")
 
   (assert-equal test
     &(Datetime.date 2024 2 15)
@@ -1900,33 +1908,51 @@
     "between: sub-day difference in seconds")
 
   (assert-equal test
-    3600
+    3600l
     (Duration.to-seconds &(Duration.hours 1))
     "to-seconds: exact")
   (assert-equal test
-    1
+    1l
     (Duration.to-minutes &(Duration.seconds 90))
     "to-minutes: truncates positive remainder")
   (assert-equal test
-    0
+    0l
     (Duration.to-minutes &(Duration.seconds 59))
     "to-minutes: below one minute is zero")
   (assert-equal test
-    -1
+    -1l
     (Duration.to-minutes &(Duration.seconds -90))
     "to-minutes: truncates toward zero for negatives")
   (assert-equal test
-    1
+    1l
     (Duration.to-hours &(Duration.minutes 90))
     "to-hours: truncates remainder")
   (assert-equal test
-    1
+    1l
     (Duration.to-days &(Duration.hours 25))
     "to-days: truncates remainder")
   (assert-equal test
-    7
+    7l
     (Duration.to-days &(Duration.weeks 1))
     "to-days: one week is seven days")
+  (assert-equal test
+    (* 3551l 604800l)
+    (Duration.to-seconds &(Duration.weeks 3551))
+    "weeks: a count past the 32-bit second range does not wrap")
+  (assert-equal test
+    (* 24856l 86400l)
+    (Duration.to-seconds &(Duration.days 24856))
+    "days: a count past the 32-bit second range does not wrap")
+  (assert-equal test
+    (* 36525l 86400l)
+    (Duration.to-seconds
+      &(Duration.between &(Datetime.date 2100 1 1) &(Datetime.date 2000 1 1)))
+    "between: a century does not wrap")
+  (assert-equal test
+    36525l
+    (Duration.to-days
+      &(Duration.between &(Datetime.date 2100 1 1) &(Datetime.date 2000 1 1)))
+    "to-days: a century is 36525 days")
 
   (assert-equal test
     &(Duration.seconds 90)
@@ -1964,6 +1990,9 @@
   (assert-true test
     (Duration.= &(Duration.minutes 1) &(Duration.seconds 60))
     "=: equal durations from different units")
+  (assert-false test
+    (Duration.= &(Duration.days 49710) &(Duration.seconds -23296))
+    "=: deltas that collide when truncated to 32 bits are not equal")
   (assert-false test
     (Duration.= &(Duration.minutes 1) &(Duration.seconds 61))
     "=: unequal durations")
@@ -1979,12 +2008,28 @@
   (assert-false test
     (Duration.> &(Duration.seconds 30) &(Duration.minutes 1))
     ">: shorter is not greater")
+  (assert-true test
+    (Duration.> &(Duration.days 30000) &(Duration.days 1))
+    ">: a delta past the 32-bit second range is still the longer one")
+  (assert-false test
+    (Duration.< &(Duration.days 30000) &(Duration.days 1))
+    "<: a delta past the 32-bit second range is not the shorter one")
   (assert-true test
     (Duration.zero? &(Duration.seconds 0))
     "zero?: the zero delta")
   (assert-false test
     (Duration.zero? &(Duration.seconds 1))
     "zero?: a non-zero delta")
+  (assert-false test
+    (Duration.zero?
+      &(Duration.plus &(Duration.days 49710) &(Duration.seconds 23296)))
+    "zero?: a delta of exactly 2^32 seconds is not the zero delta")
+  (assert-true test
+    (Duration.pos? &(Duration.days 30000))
+    "pos?: a delta past the 32-bit second range is positive")
+  (assert-false test
+    (Duration.neg? &(Duration.days 30000))
+    "neg?: a delta past the 32-bit second range is not negative")
   (assert-true test (Duration.pos? &(Duration.seconds 1)) "pos?: positive delta")
   (assert-false test
     (Duration.pos? &(Duration.seconds -1))
@@ -2034,4 +2079,39 @@
   (assert-equal test
     &@"-1d 2h 3m 4s"
     &(Duration.str &(Duration.seconds -93784))
-    "str: negative multi-unit"))
+    "str: negative multi-unit")
+  (assert-equal test
+    &@"36525d 1h 1m 1s"
+    &(Duration.str
+      &(Duration.plus &(Duration.days 36525) &(Duration.seconds 3661)))
+    "str: multi-decade delta")
+  (assert-equal test
+    &@"-36525d"
+    &(Duration.str &(Duration.negate &(Duration.days 36525)))
+    "str: negative multi-decade delta")
+
+  (assert-equal test
+    &@"2100-01-01"
+    &(Datetime.isoformat
+      &(Duration.add &(Datetime.date 2000 1 1) &(Duration.days 36525)))
+    "add: a century-long delta lands on the right date")
+  (assert-equal test
+    &@"2000-01-01"
+    &(Datetime.isoformat
+      &(Duration.sub &(Datetime.date 2100 1 1) &(Duration.days 36525)))
+    "sub: a century-long delta lands on the right date")
+  (assert-equal test
+    &@"2124-03-16 13:30:45"
+    &(Datetime.strftime
+      &(Duration.add
+        &(Datetime.init 2024
+                        3
+                        15
+                        (Maybe.Just 12)
+                        (Maybe.Just 30)
+                        (Maybe.Just 45)
+                        (Maybe.Nothing)
+                        (Maybe.Nothing))
+        &(Duration.plus &(Duration.days 36525) &(Duration.hours 1)))
+      "%Y-%m-%d %H:%M:%S")
+    "add: a century-long delta keeps the time of day"))
diff --git a/time.carp b/time.carp
index 0b19afe..216d754 100644
--- a/time.carp
+++ b/time.carp
@@ -1236,7 +1236,9 @@ earlier. This is a timezone-unaware comparison, similar to `>` and `<`.")
           time-b (+ (* (Maybe.from @(hours b) 0) HOUR)
                     (+ (* (Maybe.from @(minutes b) 0) MINUTE)
                        (Maybe.from @(seconds b) 0)))]
-      (+ (* (- ord-a ord-b) DAY) (- time-a time-b))))
+      (+
+        (* (Long.from-int (- ord-a ord-b)) (Long.from-int DAY))
+        (Long.from-int (- time-a time-b)))))
 
   (doc add-months "adds `n` months to the `Datetime` `dt`.
 
@@ -1279,7 +1281,7 @@ clamped to February 28.
             @(nanoseconds dt)
             @(tz dt)))))
 
-(deftype Duration [seconds- Int])
+(deftype Duration [seconds- Long])
 
 (doc Duration "this is a complementary module to be used with `Datetime` and
 is concerned with adding and subtracting time values of the scale from seconds
@@ -1311,21 +1313,36 @@ Example:
   (private init)
 
   (doc seconds "creates a delta representing a number of seconds `n`.")
-  (defn seconds [n] (init n))
+  (defn seconds [n] (init (Long.from-int n)))
   (doc minutes "creates a delta representing a number of minutes `n`.")
-  (defn minutes [n] (seconds (* n 60)))
+  (defn minutes [n] (init (* (Long.from-int n) 60l)))
   (doc hours "creates a delta representing a number of hours `n`.")
-  (defn hours [n] (minutes (* n 60)))
+  (defn hours [n] (init (* (Long.from-int n) 3600l)))
   (doc days "creates a delta representing a number of days `n`.")
-  (defn days [n] (hours (* n 24)))
+  (defn days [n] (init (* (Long.from-int n) 86400l)))
   (doc weeks "creates a delta representing a number of weeks `n`.")
-  (defn weeks [n] (days (* n 7)))
+  (defn weeks [n] (init (* (Long.from-int n) 604800l)))
+
+  (private shift)
+  (hidden shift)
+  ; a Datetime names its date by an Int ordinal, so clamp rather than wrap
+  (defn shift [dt total]
+    (let-do [nd @dt
+             ord (Long.clamp (Long.from-int Int.MIN)
+                             (Long.from-int Int.MAX)
+                             (+ (Long.from-int (Datetime.to-ordinal dt))
+                                (/ total 86400l)))
+             d (Datetime.from-ordinal (Long.to-int ord))]
+      (Datetime.set-day! &nd @(Datetime.day &d))
+      (Datetime.set-month! &nd @(Datetime.month &d))
+      (Datetime.set-year! &nd @(Datetime.year &d))
+      (Datetime.add-seconds &nd (Long.to-int (mod total 86400l)))))
 
   (doc add "adds a `Duration` `delta` to a `Datetime` `dt`.")
-  (defn add [dt delta] (Datetime.add-seconds dt @(seconds- delta)))
+  (defn add [dt delta] (shift dt @(seconds- delta)))
 
   (doc sub "subtracts a `Duration` `delta` from a `Datetime` `dt`.")
-  (defn sub [dt delta] (Datetime.add-seconds dt (neg @(seconds- delta))))
+  (defn sub [dt delta] (shift dt (neg @(seconds- delta))))
 
   (doc between
     "returns the signed difference between the `Datetime`s `a` and `b`
@@ -1341,16 +1358,16 @@ earlier, mirroring `Datetime.diff`.")
 
   (doc to-minutes "returns the total number of whole minutes represented by the
 delta `d`, truncating any remaining seconds toward zero.")
-  (defn to-minutes [d] (/ @(seconds- d) 60))
+  (defn to-minutes [d] (/ @(seconds- d) 60l))
 
   (doc to-hours
     "returns the total number of whole hours represented by the delta
 `d`, truncating any remainder toward zero.")
-  (defn to-hours [d] (/ @(seconds- d) 3600))
+  (defn to-hours [d] (/ @(seconds- d) 3600l))
 
   (doc to-days "returns the total number of whole days represented by the delta
 `d`, truncating any remainder toward zero.")
-  (defn to-days [d] (/ @(seconds- d) 86400))
+  (defn to-days [d] (/ @(seconds- d) 86400l))
 
   (doc plus "returns the sum of the deltas `a` and `b`.")
   (defn plus [a b] (init (+ @(seconds- a) @(seconds- b))))
@@ -1362,32 +1379,32 @@ delta `d`, truncating any remaining seconds toward zero.")
   (defn negate [d] (init (neg @(seconds- d))))
 
   (doc scale "returns the delta `d` scaled by the integer factor `n`.")
-  (defn scale [d n] (init (* @(seconds- d) n)))
+  (defn scale [d n] (init (* @(seconds- d) (Long.from-int n))))
 
   (doc mul "returns the delta `d` scaled by the integer factor `n`. An alias for
 `scale`.")
   (defn mul [d n] (scale d n))
 
   (doc = "checks whether the deltas `a` and `b` represent the same duration.")
-  (defn = [a b] (Int.= @(seconds- a) @(seconds- b)))
+  (defn = [a b] (Long.= @(seconds- a) @(seconds- b)))
   (implements = Duration.=)
 
   (doc < "checks whether the delta `a` is shorter than the delta `b`.")
-  (defn < [a b] (Int.< @(seconds- a) @(seconds- b)))
+  (defn < [a b] (Long.< @(seconds- a) @(seconds- b)))
   (implements < Duration.<)
 
   (doc > "checks whether the delta `a` is longer than the delta `b`.")
-  (defn > [a b] (Int.> @(seconds- a) @(seconds- b)))
+  (defn > [a b] (Long.> @(seconds- a) @(seconds- b)))
   (implements > Duration.>)
 
   (doc zero? "checks whether the delta `d` represents no elapsed time.")
-  (defn zero? [d] (Int.= @(seconds- d) 0))
+  (defn zero? [d] (Long.= @(seconds- d) 0l))
 
   (doc pos? "checks whether the delta `d` represents a positive duration.")
-  (defn pos? [d] (Int.> @(seconds- d) 0))
+  (defn pos? [d] (Long.> @(seconds- d) 0l))
 
   (doc neg? "checks whether the delta `d` represents a negative duration.")
-  (defn neg? [d] (Int.< @(seconds- d) 0))
+  (defn neg? [d] (Long.< @(seconds- d) 0l))
 
   (doc str
     "renders the delta `d` in a human-readable form such as `1d 2h 3m 4s`.
@@ -1396,18 +1413,19 @@ Components that are zero are omitted, a negative delta is prefixed with a `-`,
 and the zero delta renders as `0s`.")
   (defn str [d]
     (let [total @(seconds- d)]
-      (if (Int.= total 0)
+      (if (Long.= total 0l)
         @"0s"
-        (let-do [a (Int.abs total)
-                 days (/ a 86400)
-                 hrs (/ (mod a 86400) 3600)
-                 mins (/ (mod a 3600) 60)
-                 secs (mod a 60)
+        (let-do [a (Long.abs total)
+                 days (/ a 86400l)
+                 hrs (Long.to-int (/ (mod a 86400l) 3600l))
+                 mins (Long.to-int (/ (mod a 3600l) 60l))
+                 secs (Long.to-int (mod a 60l))
                  parts []]
-          (when (> days 0) (Array.push-back! &parts (fmt "%dd" days)))
+          (when (> days 0l)
+            (Array.push-back! &parts (fmt "%sd" &(Long.str days))))
           (when (> hrs 0) (Array.push-back! &parts (fmt "%dh" hrs)))
           (when (> mins 0) (Array.push-back! &parts (fmt "%dm" mins)))
           (when (> secs 0) (Array.push-back! &parts (fmt "%ds" secs)))
           (String.concat
-            &[(if (Int.< total 0) @"-" @"") (String.join " " &parts)])))))
+            &[(if (Long.< total 0l) @"-" @"") (String.join " " &parts)])))))
   (implements str Duration.str))

From 2db7e70b7db80554fd6017d2e91c2c23488f1b66 Mon Sep 17 00:00:00 2001
From: Veit Heller 
Date: Mon, 31 Aug 2026 15:36:46 -0500
Subject: [PATCH 2/2] Saturate Duration shifts to from-ordinal's domain and add
 from-seconds

---
 docs/Duration.html | 27 +++++++++++++++++++++++++++
 test/time.carp     | 37 ++++++++++++++++++++++++++++++++++++-
 time.carp          | 33 +++++++++++++++++++++++++--------
 3 files changed, 88 insertions(+), 9 deletions(-)

diff --git a/docs/Duration.html b/docs/Duration.html
index dfb88e8..f0e3339 100644
--- a/docs/Duration.html
+++ b/docs/Duration.html
@@ -131,6 +131,9 @@ 

adds a Duration delta to a Datetime dt.

+

A Duration can name a span far wider than the range of dates a Datetime can +represent. A result that would fall outside that range saturates at the first +or last representable date rather than wrapping.

@@ -217,6 +220,29 @@

+
+ +

+ from-seconds +

+
+
+ defn +
+

+ (Fn [Long] Duration) +

+
+                        (from-seconds n)
+                    
+

+

creates a delta from a raw second count n.

+

The unit constructors above take an Int count, which cannot name a delta +longer than about 68 years in seconds. This is the counterpart to +to-seconds, so a delta of any width round-trips through it.

+ +

+

subtracts a Duration delta from a Datetime dt.

+

Out-of-range results saturate, as in add.

diff --git a/test/time.carp b/test/time.carp index 3bc0fb9..88550a3 100644 --- a/test/time.carp +++ b/test/time.carp @@ -2114,4 +2114,39 @@ (Maybe.Nothing)) &(Duration.plus &(Duration.days 36525) &(Duration.hours 1))) "%Y-%m-%d %H:%M:%S") - "add: a century-long delta keeps the time of day")) + "add: a century-long delta keeps the time of day") + + (assert-equal test + &@"0001-01-01" + &(Datetime.isoformat + &(Duration.sub &(Datetime.date 2024 1 1) &(Duration.days 739000))) + "sub: a delta reaching before the first ordinal saturates") + (assert-equal test + &@"5879611-07-11" + &(Datetime.isoformat + &(Duration.add &(Datetime.date 2024 1 1) &(Duration.days 2147483647))) + "add: a delta past the last ordinal saturates") + (assert-equal test + &@"5879611-07-11" + &(Datetime.isoformat + &(Duration.add + &(Datetime.init 2024 + 1 + 1 + (Maybe.Just 23) + (Maybe.Just 0) + (Maybe.Just 0) + (Maybe.Nothing) + (Maybe.Nothing)) + &(Duration.plus &(Duration.days 2147483647) &(Duration.hours 23)))) + "add: saturation leaves room for the time-of-day carry") + + (assert-equal test + (* 36525l 86400l) + (Duration.to-seconds &(Duration.from-seconds (* 36525l 86400l))) + "from-seconds: holds a count wider than an Int") + (assert-true test + (Duration.= + &(Duration.from-seconds (Duration.to-seconds &(Duration.days 36525))) + &(Duration.days 36525)) + "from-seconds: round-trips to-seconds")) diff --git a/time.carp b/time.carp index 216d754..bf21c3d 100644 --- a/time.carp +++ b/time.carp @@ -1323,25 +1323,42 @@ Example: (doc weeks "creates a delta representing a number of weeks `n`.") (defn weeks [n] (init (* (Long.from-int n) 604800l))) + (doc from-seconds "creates a delta from a raw second count `n`. + +The unit constructors above take an `Int` count, which cannot name a delta +longer than about 68 years in seconds. This is the counterpart to +[`to-seconds`](#to-seconds), so a delta of any width round-trips through it.") + (defn from-seconds [n] (init n)) + (private shift) (hidden shift) - ; a Datetime names its date by an Int ordinal, so clamp rather than wrap + ; A Datetime names its date by an Int ordinal, and from-ordinal is only + ; defined from 1 up, so saturate at both ends rather than wrap out of the + ; representable range. The time-of-day remainder is applied first so that its + ; own day carry happens before the clamp and cannot push back out of range. (defn shift [dt total] - (let-do [nd @dt - ord (Long.clamp (Long.from-int Int.MIN) + (let-do [nd (Datetime.add-seconds dt (Long.to-int (mod total 86400l))) + ord (Long.clamp 1l (Long.from-int Int.MAX) - (+ (Long.from-int (Datetime.to-ordinal dt)) - (/ total 86400l))) + (+ + (Long.from-int (Datetime.to-ordinal &nd)) + (/ total 86400l))) d (Datetime.from-ordinal (Long.to-int ord))] (Datetime.set-day! &nd @(Datetime.day &d)) (Datetime.set-month! &nd @(Datetime.month &d)) (Datetime.set-year! &nd @(Datetime.year &d)) - (Datetime.add-seconds &nd (Long.to-int (mod total 86400l))))) + nd)) - (doc add "adds a `Duration` `delta` to a `Datetime` `dt`.") + (doc add "adds a `Duration` `delta` to a `Datetime` `dt`. + +A `Duration` can name a span far wider than the range of dates a `Datetime` can +represent. A result that would fall outside that range saturates at the first +or last representable date rather than wrapping.") (defn add [dt delta] (shift dt @(seconds- delta))) - (doc sub "subtracts a `Duration` `delta` from a `Datetime` `dt`.") + (doc sub "subtracts a `Duration` `delta` from a `Datetime` `dt`. + +Out-of-range results saturate, as in [`add`](#add).") (defn sub [dt delta] (shift dt (neg @(seconds- delta)))) (doc between