Skip to content

fix timestamp conversion and add-seconds bugs#6

Merged
hellerve merged 3 commits into
masterfrom
claude/fix-timestamp-and-add-seconds-bugs
Jun 1, 2026
Merged

fix timestamp conversion and add-seconds bugs#6
hellerve merged 3 commits into
masterfrom
claude/fix-timestamp-and-add-seconds-bugs

Conversation

@carpentry-agent

Copy link
Copy Markdown

Summary

Fixes three documented bugs in the time library:

to-unix-timestamp / from-unix-timestamp out-of-bounds array access:

  • to-unix used (Array.range 1970 y 1) which included the target year y, overcounting by a full year of seconds. Fixed by ranging to (Int.dec y) with a guard for y <= 1970.
  • from-unix-timestamp applied Int.dec to the leap-day count (producing -1 for epoch) and used Array.index-of to find the month, which returned index 0 instead of 1 because DAYS-BEFORE-MONTH has 0 at both positions. Replaced with a find-month-from-days helper that iterates from month 1 upward.

add-seconds off-by-one with negative values:

  • The negative branch used dec as a borrow mechanism, but C's truncating integer division requires floor-division for correct carry propagation. Added a div- helper (matching mod-'s Python-style semantics) and removed all dec calls. Subtracting 5 minutes from 00:00:00 now correctly yields 23:55:00 instead of 23:54:59.

Re-enables the two previously skipped unix-timestamp round-trip tests. All 80 tests pass.


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

- to-unix-timestamp: year range included the target year, overcounting
  by a full year's worth of seconds
- from-unix-timestamp: Int.dec on leap day count was wrong; month
  lookup via index-of found index 0 instead of 1 due to duplicate
  sentinel in DAYS-BEFORE-MONTH
- add-seconds: negative branch used dec as a borrow hack but C's
  truncating integer division needs floor-division; replaced with a
  div- helper matching mod-'s Python semantics

Re-enables the two skipped unix-timestamp tests and fixes the
add-seconds expected value (23:55:00, not 23:54:59).

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Build: cannot verify locally (pre-existing tm_zone const-qualifier error on ARM/Linux clang — also fails on master)
Tests: cannot run locally for the same reason
CI: pass (macOS, carp -x test/time.carp — all 80 tests)

Findings

1. to-unix-timestamp fix — correct ✓

Old (Array.range 1970 y 1) is inclusive on both ends, so it counted the target year's days twice. New (Array.range 1970 (Int.dec y) 1) correctly ranges through the year before the target. The (<= y 1970) guard handles the epoch year.

2. add-seconds fix — correct ✓

The core insight is right: C's truncating integer division gives wrong results for negative carries. Example traced manually:

  • Old: subtract 300s from 00:00:00 → dec(0 + -300) = -301, seconds = mod-(-301, 60) = 59, carry = (/ -301 60) = -5, then dec(-5) = -6, minutes = mod-(-6, 60) = 54 → 23:54:59 (wrong)
  • New: 0 + -300 = -300, seconds = mod-(-300, 60) = 0, carry = div-(-300, 60) = -5, minutes = mod-(-5, 60) = 55 → 23:55:00 (correct)

Removing dec and using floor-division (div-) throughout is the right fix. The refactored mod- now shares div- rather than inlining the floor calculation — cleaner.

3. from-unix-timestamp epoch fix — correct but introduces a boundary regression

The epoch bug is fixed: find-month-from-days 0 correctly returns month 1 (January), avoiding the Array.index-of duplicate-zero trap.

However, find-month-from-days uses <=:

(when (<= @(Array.unsafe-nth &DAYS-BEFORE-MONTH i) d) (set! m i))

This regresses Feb 29 in leap years. Trace for Feb 29, 1972 (ts = 68169600):

  • y = 2, ld = 0 (1970-1971 have no leap years), d = 789 - 730 = 59
  • find-month-from-days 59: at i=3, DAYS-BEFORE-MONTH[3] = 59, (<= 59 59) → true, sets m=3
  • Result: March 1 instead of Feb 29

The old code used < in get-m-until which got this case right but was wrong in the opposite direction: in a non-leap year, d=59 is March 1 but < would give February 29 (which doesn't exist).

Neither approach is fully correctDAYS-BEFORE-MONTH is static and doesn't adjust for leap years. The correct fix needs leap-year awareness in find-month-from-days, e.g.:

(defn find-month-from-days [d is-leap]
  (let-do [m 1]
    (for [i 2 13]
      (let [adj (if (and is-leap (>= i 3)) 1 0)]
        (when (<= (+ @(Array.unsafe-nth &DAYS-BEFORE-MONTH i) adj) d) (set! m i))))
    m))

The year is known at the call site (y + 1970) so is-leap can be passed.

Summary of impact

Bug Status
to-unix-timestamp off-by-one year Fixed ✓
from-unix-timestamp at epoch Fixed ✓
from-unix-timestamp on Feb 29 leap years Regressed (was correct in old code)
add-seconds negative floor division Fixed ✓

Verdict: revise

The to-unix-timestamp and add-seconds fixes are solid. The from-unix-timestamp fix solves the documented epoch bug but introduces a leap-year boundary regression. Please add leap-year awareness to find-month-from-days (the year is available at the call site) or at minimum document the limitation. Adding a round-trip test for a leap-year Feb 29 date would catch this.

find-month-from-days used static DAYS-BEFORE-MONTH offsets which
misclassified Feb 29 as March 1 in leap years. Pass leap-year flag
and adjust month boundaries for months >= 3. Also switch mdays in
from-unix-timestamp to use the existing days-before-month helper
which already handles leap years.

Add round-trip test for Feb 29, 1972.
@carpentry-agent

Copy link
Copy Markdown
Author

Addressed the leap-year regression in find-month-from-days identified by @carpentry-reviewer:

carpentry-reviewer — Feb 29 leap-year regression in find-month-from-days:

  • Added an is-leap parameter to find-month-from-days. For months >= 3, the DAYS-BEFORE-MONTH threshold is bumped by 1 in leap years, so d=59 (Feb 29) no longer matches March.
  • Also switched the mdays calculation in from-unix-timestamp from raw DAYS-BEFORE-MONTH array access to the existing days-before-month helper, which already handles the leap-year offset. Without this, the day-within-month would be off by 1 for months 3–12 in leap years.
  • Added a round-trip test: from-unix-timestamp(to-unix-timestamp(1972-02-29 00:00:00)) = 1972-02-29 00:00:00.

@carpentry-agent

Copy link
Copy Markdown
Author

Addressed the leap-year regression identified by @carpentry-reviewer:

  • find-month-from-days now takes a leap parameter. For months >= 3, the DAYS-BEFORE-MONTH threshold is adjusted by +1 in leap years, so Feb 29 no longer falls through to March.
  • mdays in from-unix-timestamp now uses days-before-month (which is already leap-aware) instead of a raw DAYS-BEFORE-MONTH lookup, fixing day-of-month calculation for months >= 3 in leap years.
  • Added a round-trip test for 1972-02-29 00:00:00 (Feb 29 in a leap year) that goes through to-unix-timestampfrom-unix-timestamp and checks equality.

Trace for Feb 29, 1972 (the failing case):

  • d = 59 (day-of-year, 0-based)
  • Old: DAYS-BEFORE-MONTH[3] = 59, (<= 59 59) → true → month 3 (March) ✗
  • New: 59 + adj(1) = 60, (<= 60 59) → false → month stays 2 (February) ✓

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Build: cannot verify locally (pre-existing tm_zone const-qualifier error on ARM/Linux — also fails on main)
Tests: cannot run locally for the same reason
CI: pass (macOS, all tests green)

Prior feedback

Issue Status
Feb 29 leap-year regression in find-month-from-days Fixed ✓ (time.carp:336-342: leap parameter, adj +1 for months >= 3)
mdays uses raw DAYS-BEFORE-MONTH instead of leap-aware helper Fixed ✓ in from-unix-timestamp (time.carp:354: uses days-before-month)
Missing leap-year round-trip test Added ✓ (test/time.carp:213-232)

Findings

1. CRITICAL: to-unix still uses raw DAYS-BEFORE-MONTH for month offset (time.carp:317)

(+ (* yy DAY)
   (+
     (* @(Array.unsafe-nth &DAYS-BEFORE-MONTH m) DAY)  ; <-- HERE
     ...

The to-unix helper (which backs to-unix-timestamp) computes the day offset for the month using the static DAYS-BEFORE-MONTH array. This array does not account for the leap day. For any date with month >= 3 in a leap year, the timestamp will be off by exactly 86400 seconds (one day too low).

Concrete example: Both 1972-02-29 00:00:00 and 1972-03-01 00:00:00 would produce the same timestamp (68169600). The correct timestamp for March 1 should be 68256000.

Why the current test doesn't catch this: The Feb 29 round-trip test (test/time.carp:213-232) uses month=2, which is unaffected by the adjustment. A test for any date in a leap year with month >= 3 would fail.

Fix: Replace line 317 with:

(* (days-before-month (+ y 1970) m) DAY)

This uses the same days-before-month helper (time.carp:148-151) that was correctly introduced in from-unix-timestamp. The year needs to be absolute (y is relative to 1970 at that point).

Regression test to add:

(assert-equal test
  &(Datetime.init 1972 3 1 (Maybe.Just 0) (Maybe.Just 0) (Maybe.Just 0)
                  (Maybe.Nothing) (Maybe.Nothing))
  &(Datetime.from-unix-timestamp
    (Datetime.to-unix-timestamp
      &(Datetime.init 1972 3 1 (Maybe.Just 0) (Maybe.Just 0) (Maybe.Just 0)
                      (Maybe.Nothing) (Maybe.Nothing))))
  "from-unix-timestamp round-trips March 1 in leap year")

2. Correctly fixed: from-unix-timestamp leap-year awareness ✓

find-month-from-days (time.carp:336-342) correctly adjusts thresholds for months >= 3 in leap years. Traced:

  • Feb 29 in leap year (d=59): month=2 ✓
  • March 1 in leap year (d=60): month=3 ✓
  • Jan 1 (d=0): month=1 ✓

3. Correctly fixed: to-unix-timestamp year-range guard ✓

Line 309: (if (<= y 1970) 0 ...) correctly handles the epoch year. Range goes to (Int.dec y) which counts only prior years.

4. Correctly fixed: add-seconds floor division ✓

The div- helper (time.carp:241) provides correct floor-division semantics. Removing dec and using div- throughout eliminates the off-by-one. Traced: subtracting 300s from 00:00:00 correctly yields 23:55:00.

5. Pre-existing (not in scope): yearday also ignores leap years

Line 213: (+ @(Array.unsafe-nth &DAYS-BEFORE-MONTH @(month dt)) @(day dt)) — same raw-array issue affecting strftime %j/%U/%W for month >= 3 in leap years. Not introduced by this PR, just noting it.

Verdict: revise

The from-unix-timestamp and add-seconds fixes are solid, but to-unix-timestamp has a remaining leap-year bug at time.carp:317. Replace the raw DAYS-BEFORE-MONTH lookup with (days-before-month (+ y 1970) m) and add a round-trip test for a date with month >= 3 in a leap year.

Replace raw DAYS-BEFORE-MONTH lookup with days-before-month helper
in to-unix, which already handles the leap-day offset. Add March 1
leap year round-trip test.
@carpentry-agent

Copy link
Copy Markdown
Author

Addressed the remaining leap-year bug identified by @carpentry-reviewer:

carpentry-reviewer — to-unix uses raw DAYS-BEFORE-MONTH at line 317:

  • Replaced @(Array.unsafe-nth &DAYS-BEFORE-MONTH m) with (days-before-month y m) in to-unix, which already handles the leap-day offset for months >= 3. This matches the fix already applied in from-unix-timestamp.
  • Added a round-trip test for 1972-03-01 00:00:00 (March 1 in a leap year, month >= 3) to catch this class of bug.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Build: cannot verify locally (pre-existing tm_zone const-qualifier error on ARM/Linux clang — also fails on main)
Tests: cannot run locally for the same reason
CI: pass (macOS, all tests green)

Prior feedback

Issue Status
Feb 29 leap-year regression in find-month-from-days (round 1) Fixed ✓ (time.carp:336-342: leap parameter with adj +1 for months >= 3)
mdays uses raw DAYS-BEFORE-MONTH in from-unix-timestamp (round 1) Fixed ✓ (time.carp:354: uses days-before-month)
Missing leap-year round-trip test (round 1) Added ✓ (test/time.carp:213-232)
to-unix uses raw DAYS-BEFORE-MONTH at line 317 (round 2) Fixed ✓ (time.carp:316: now (days-before-month y m))
Missing March 1 leap-year test (round 2) Added ✓ (test/time.carp:234-256)

All previously flagged issues have been addressed.

Findings

Traced all three fixes manually:

1. to-unix-timestamp year-range fix — correct ✓

Line 309-314: (<= y 1970) guard returns 0, otherwise (Array.range 1970 (Int.dec y) 1) correctly excludes the target year. The days-before-month call at line 316 receives the absolute year (from @(year dt) at line 325), which is-leap expects. Traced epoch, Feb 29 1972, Mar 1 1972, Dec 31 1972, Jul 4 2023 — all produce correct timestamps.

2. from-unix-timestamp epoch fix — correct ✓

find-month-from-days (lines 336-342) correctly returns month 1 for d=0 (epoch), avoids the old Array.index-of duplicate-zero trap. Leap-year awareness via adj is correct: Feb 29 1972 (d=59) → month 2, Mar 1 1972 (d=60) → month 3.

3. add-seconds floor-division fix — correct ✓

div- (line 241) provides proper floor division. Traced: subtract 300s from 00:00:00 → 23:55:00 ✓, subtract 1s from 00:00:00 → 23:59:59 ✓, subtract 3661s from 01:01:01 → 00:00:00 ✓. All correct.

Pre-existing issues (not introduced by this PR, not blocking)

  1. from-unix-timestamp year overshoot (time.carp:347): y = ts / YEAR divides by a fixed 365-day year, so as leap days accumulate the estimated year overshoots. By 2023 (~13 accumulated leap years), Dec 19–31 produce wrong results (day=0 or negative). This is pre-existing and the correct fix (iterative year search) would be a separate PR.

  2. yearday uses raw DAYS-BEFORE-MONTH (time.carp:212-213): off by 1 for months >= 3 in leap years. Also affects strftime %j/%U/%W. Pre-existing.

Verdict: merge

All three bug fixes are correct and well-tested. Every issue from the prior two review rounds has been addressed. The pre-existing from-unix-timestamp year-overshoot and yearday bugs are real but out of scope for this PR — they should be tracked as separate issues.

@hellerve hellerve merged commit 887ce98 into master Jun 1, 2026
1 check passed
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