Skip to content

Modernize Go style and make the calendar math table-driven#7

Merged
mjradwin merged 1 commit into
mainfrom
modernize-go-style
Jul 12, 2026
Merged

Modernize Go style and make the calendar math table-driven#7
mjradwin merged 1 commit into
mainfrom
modernize-go-style

Conversation

@mjradwin

Copy link
Copy Markdown
Member

Bumps Go to 1.18 and greg to v1.1.0, modernizes the package for current Go style, and speeds up the conversion hot paths. The exported API is unchanged, with one deliberate behavior fix called out below.

Table-driven calendar math

A Hebrew year's entire shape — leap or not, long Cheshvan, short Kislev — is determined by its length, and there are only six possible lengths. The month lengths and each month's day-offset within the year are now tabulated once at startup, so conversions are lookups instead of loops.

ToRD summed month lengths one at a time, and each DaysInMonth in that loop could call DaysInYear, which computes elapsedDays twice. FromRD was worse: it searched for the month by calling ToRD once per candidate, so the whole month-summing loop ran again on every step of the search.

HDate is now an immutable value

Abs() had a pointer receiver and memoized into hd.abs, but every other method has a value receiver — so Weekday(), Next() and friends took the address of a temporary copy and threw the memo away. New() never set abs, so those paths recomputed ToRD on every single call. abs is now computed at construction and Abs() has a value receiver.

Widening a method from a pointer to a value receiver only adds to the method set, so this does not break consumers.

Benchmarks

ToRD              43.4 ns -> 5.8 ns
FromRD             155 ns -> 13.4 ns
RoundTrip          156 ns -> 13.4 ns
Greg              1125 ns -> 13.6 ns
FromGregorian      384 ns -> 18.5 ns
Next              61.9 ns -> 11.9 ns
Weekday            6.6 ns -> 1.6 ns
DaysInMonth        8.1 ns -> 5.6 ns
GetYahrzeit       96.2 ns -> 47.2 ns
MonthFromName     44.2 ns -> 3.1 ns   1 alloc -> 0
MonthName("he")    8.7 ns -> 4.7 ns
String            57.5 ns -> 35.5 ns  2 allocs -> 1
StripNikkud       95.5 ns -> 60.4 ns
StripNikkud/clean 83.6 ns -> 16.9 ns  2 allocs -> 0

Greg() owes most of its win to the greg v1.1.0 bump. New() regresses 3.8 ns -> 7.4 ns because it now computes abs up front, which is what buys Weekday, Next and Greg their speedups; anything that actually uses an HDate comes out far ahead.

⚠️ Behavior change: DayOnOrBefore before the common era

Go's % truncates toward zero, so for the negative R.D. numbers of Hebrew years before ~3762, DayOnOrBefore returned a day after rataDie — the opposite of what it promises. Downstream this placed Shabbat Shuva after Yom Kippur and Shabbat Zachor after Purim; every special Shabbat landed exactly a week late. The modulus is now floored by hand.

This is the only behavior change in the PR. Verified by building hebcal-go against this branch: with just this one fix reverted, all of hebcal-go passes, so nothing else in the rewrite moves any output. Modern dates are unaffected.

This requires a follow-up in hebcal-go. Its golden event counts for Hebrew years 1 and 2 encode the old bug and must be updated together with a bump to the hdate release containing this fix:

  • TestYear1: 79 -> 80
  • TestYear2: 127 -> 128

Suggest tagging this as v1.4.0 (minor, not patch) on account of the behavior change.

Testing

Because the calendar core was rewritten, passing the existing tests wasn't sufficient. Added:

  • a differential test pinning the new tables against the original loop-based implementations over every month and day of Hebrew years 1–8000, including the out-of-range months the old loops happened to accept;
  • an exhaustive round trip over all ~2.9M days from the epoch forward, checking FromRD/ToRD/New all agree;
  • a guard that DayOnOrBefore lands on the requested weekday for negative R.D.

go vet clean, gofmt clean, go test -race clean.

Also

  • UnmarshalJSON reported no error for out-of-range values, silently producing an HDate that no calendar day corresponds to (e.g. {"hd": 99}). It now validates via a non-panicking constructor and returns an error. This is stricter than before — anything feeding hdate sloppy JSON will now see errors where it previously got a silently-corrupt date.
  • MonthName called strings.ToLower on every invocation, allocating just to compare against two constants; now uses EqualFold. Hebrew month names moved from a map to an array.
  • MonthFromName allocated a lowercased copy and a []rune of the whole string, then read two runes of it. Now decodes just those two.
  • Exported ErrMonthName so callers can use errors.Is instead of matching on the message.
  • HebrewStripNikkud converted to []rune to iterate (range already yields runes) and sized Grow in runes while writing bytes. Now returns the string unchanged when there is nothing to strip.
  • GetYahrzeit assigned to another HDate's private month/day fields, leaving a stale abs behind. Now uses local variables.
  • CI pinned Go 1.17 and never ran vet, so modern checks had never run against this code. Now a [1.18, stable] matrix with vet.

🤖 Generated with Claude Code

The shape of a Hebrew year is fully determined by its length, and there
are only six possible lengths. Tabulate the month lengths and the day
offset of each month within the year once at startup, so the conversions
become lookups instead of loops.

Previously ToRD summed month lengths one at a time, and each DaysInMonth
in that loop could call DaysInYear, which computes elapsedDays twice. Two
nested loops made FromRD worse: it searched for the month by calling ToRD
once per candidate, so the whole month-summing loop ran again on every
step of the search.

Also make HDate an immutable value. Abs() had a pointer receiver and
memoized into hd.abs, but every other method has a value receiver, so
Weekday(), Next() and friends took the address of a temporary copy and
threw the memo away -- New() never set abs, so those paths recomputed
ToRD on every single call. Compute abs at construction instead and give
Abs() a value receiver. Widening a method from a pointer to a value
receiver only adds to the method set, so this does not break consumers.

    ToRD              43.4 ns -> 5.8 ns
    FromRD             155 ns -> 13.4 ns
    RoundTrip          156 ns -> 13.4 ns
    Greg              1125 ns -> 13.6 ns
    FromGregorian      384 ns -> 18.5 ns
    Next              61.9 ns -> 11.9 ns
    Weekday            6.6 ns -> 1.6 ns
    DaysInMonth        8.1 ns -> 5.6 ns
    GetYahrzeit       96.2 ns -> 47.2 ns
    MonthFromName     44.2 ns -> 3.1 ns   1 alloc -> 0
    MonthName("he")    8.7 ns -> 4.7 ns
    String            57.5 ns -> 35.5 ns  2 allocs -> 1
    StripNikkud       95.5 ns -> 60.4 ns
    StripNikkud/clean 83.6 ns -> 16.9 ns  2 allocs -> 0

Greg() owes most of its win to the greg v1.1.0 bump. New() regresses
3.8 ns -> 7.4 ns because it now computes abs up front, which is what
buys Weekday, Next and Greg their speedups; anything that actually uses
an HDate comes out far ahead.

Fix DayOnOrBefore for dates before the common era. Go's % truncates
toward zero, so for the negative R.D. numbers of Hebrew years before
~3762 it returned a day *after* rataDie, which is the opposite of what
the function promises. It placed Shabbat Shuva after Yom Kippur and
Shabbat Zachor after Purim -- every special Shabbat landed exactly a
week late. Floor the modulus by hand. This is the only behavior change
in this commit; hebcal-go's golden event counts for Hebrew years 1 and 2
encode the old bug and need updating.

Tests: a differential test pinning the tables against the original
loop-based implementations over every month and day of Hebrew years
1-8000, including the out-of-range months the old loops accepted; an
exhaustive round trip over all ~2.9M days from the epoch forward; and a
guard that DayOnOrBefore lands on the requested weekday for negative R.D.

Also:
- UnmarshalJSON reported no error for out-of-range values, silently
  producing an HDate that no calendar day corresponds to. Validate via a
  non-panicking constructor and return the error.
- MonthName called strings.ToLower on every invocation, allocating just
  to compare against two constants; use EqualFold. Index the Hebrew month
  names with an array rather than hashing a map.
- MonthFromName allocated a lowercased copy and a []rune of the whole
  string, then read two runes of it. Decode just those two.
- Export ErrMonthName so callers can use errors.Is instead of matching
  on the message.
- HebrewStripNikkud converted to []rune to iterate (range already yields
  runes) and sized Grow in runes while writing bytes. Return the string
  unchanged when there is nothing to strip.
- GetYahrzeit assigned to another HDate's private month/day fields,
  leaving a stale abs behind. Use local variables.
- go.mod: go 1.17 -> 1.18, greg v1.0.2 -> v1.1.0. CI pinned Go 1.17 and
  never ran vet; test on a [1.18, stable] matrix and vet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mjradwin
mjradwin merged commit 36743c0 into main Jul 12, 2026
3 checks passed
@mjradwin
mjradwin deleted the modernize-go-style branch July 12, 2026 18:10
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