Temporal: a date a calendar reports fields for is a date it reckons arithmetic in - #3502
Merged
lahma merged 1 commit intoAug 30, 2026
Merged
Conversation
lahma
force-pushed
the
temporal-range-and-table-consistency
branch
from
August 30, 2026 15:21
fbb9f48 to
e627eb0
Compare
…rithmetic in Fixes sebastienros#3483. Four calendars are backed by a System.Globalization.Calendar that covers less than Temporal's range, and their field accessors have long answered past those bounds from a reckoning of the calendar's own. Their arithmetic did not: NonIsoCalendars.CalendarDateAdd read the backing calendar directly and turned its refusal into a RangeError, so the same date under the same calendar got two verdicts about whether the engine could reckon it. The five things the walk asks that calendar -- a year's month count, its leap month's ordinal, a monthCode's ordinal in a given year, a month's length, and where a resolved (year, ordinal, day) lands in ISO -- now fall back to the conversions the accessors already read, for exactly the years the backing calendar declines. All eleven non-ISO calendars measure and add across the whole of Temporal's range. Termination survives: the reckoning is strictly monotone in (year, ordinal month) so every step of CalendarDateUntil's walk moves, it declines outright past Temporal's own range (still a CalendarRangeException, still a RangeError), and the walk's no-progress guard stays as the structural guarantee against a reckoning that saturates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
force-pushed
the
temporal-range-and-table-consistency
branch
from
August 30, 2026 15:45
e627eb0 to
c5d86d7
Compare
lahma
added a commit
that referenced
this pull request
Sep 9, 2026
… on the 33-year cycle, so the ends of Temporal's range land in the right Persian year (#4006) Backport of PR #3751 (commit 5a57a7d) from main. `PersianCalendar` spans ISO 622-03-22 to 9999-12-31 and derives those years astronomically; everything outside them is answered by an arithmetic rule, because no ephemeris covers a quarter of a million years. That rule was the 2820-year cycle (Reingold-Dershowitz / Birashk), and it is not the one the platform's `persian` calendar is: ICU places a year's first day 365 * (year - 1) + floor((8 * year + 21) / 33) days after the epoch and calls a year a leap year when floorMod(25 * year + 11, 33) < 8. Over Temporal's own range the two drift about two months apart, which put both ends of it in the wrong Persian year. Where the hand-off happens is now an ISO window this repository states -- 622-03-22 through 9999-12-31, held as `PersianTableFirstJdn`/`PersianTableLastJdn` and, for the direction that asks in Persian fields, `PersianTableHolds` -- rather than the platform's `MinSupportedDateTime`/`MaxSupportedDateTime` and the `ArgumentOutOfRangeException` `ToDateTime` raises outside them. Adapted for 4.x: - `PersianDateToIso`'s maximum-day lookup keeps 4.x's `try`/`catch` around `PersianCal.GetDaysInMonth`; main's `SupportsYear`/`TryGetDaysInMonth` helpers come from the un-ported #3502/#3528 chain and do not exist here. - The `HebrewMonthSteppingTests` hunk is dropped: that file arrived with #3525, which 4.x does not carry. - Tests transcribed from NUnit to xUnit, which is what 4.x's `Jint.Tests` still is. - Only one of main's four test262 exclusions comes out here. `ZonedDateTime/from/extreme-dates.js` passes; the three `*/prototype/withCalendar/extreme-dates.js` files get past their persian rows and stop at `chinese minimum non-approximated date`, which is the lunisolar reckoning chain (#3482/#3502/#3519) 4.x does not carry, so they stay excluded with that reason recorded. - `TheYearTheWindowStopsInsideIsStillAWholeYear` is narrowed to `TheLastDayTheWindowHoldsIsStillTheTablesToPlace`: its three length assertions pin #3528's answer for the part-year 9378, and #3528 is not on this branch. Claude-Session: https://claude.ai/code/session_01SLCujwvKtTvtWD9f6RTyiF Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3483.
What was wrong
Four of the eleven non-ISO calendars are backed by a
System.Globalization.Calendarthat covers lessthan Temporal's range —
hebrewISO 1583-01-01 to 2239-09-29,persianfrom 622,chinese1901–2101,dangi918–2051. Their field accessors have long answered past those bounds from a reckoning of thecalendar's own:
HebrewAlgorithmicFromIso/HebrewAlgorithmicToIsoand the Persian pair, and theastronomical reckoning #3482 added for the lunisolar pair. Their arithmetic did not.
NonIsoCalendars.CalendarDateAddreached for the BCL calendar directly —GetLeapMonthOrdinal,MonthCodeToOrdinal,GetMonthsInYear,GetDaysInMonthCal, and finallycal.ToDateTime— and turnedthat last call's
ArgumentOutOfRangeExceptioninto theCalendarRangeException#3452 introduced.So the same date, in the same calendar, in the same engine, got two verdicts about whether the engine
could reckon it:
Nothing about a date says which of the two it will get.
withCalendarvalidates nothing,frombuildsthese dates happily,
toStringprints them,withedits them, anduntilmeasures them by day — andthen
add({ months: 1 })refuses. Six of the eleven calendars, the ones with no BCL calendar behindthem, already measured across the whole of Temporal's range; the other five stopped at the edge of a
table that is an implementation detail of Jint's, not a property of the calendar.
Refusing is permitted, which is why this is not a conformance fix
NonISODateAddis declared as returning"either a normal completion containing an ISO Date Record or a throw completion", so a
RangeErrorthere is spec-legal — that clause is exactly what let #3452 replace a hang with one. This is a coherence
fix, not a conformance one, and the argument for it is that the refusal was right against the alternative
it replaced and is not right against the one available now.
When #3452 landed, the alternative was
ClampToCalendarRange, which answered with the calendar'smaximum date whichever end had been overrun — so
subtractmoved forward anduntil's month walkstood still forever. Refusing beat that. But the conversions know the answer, and #3482 finished the last
two calendars that did not. The arithmetic was simply not asking them.
What changed
Every one of the five touchpoints now falls back to the same reckoning the field accessors read, for
exactly the years the backing calendar declines:
GetLeapMonthOrdinalLunisolarAstronomy.ForYear(...).LeapIndex + 1for chinese/dangi; the Hebrew 19-year cycle already answeredGetMonthsInYearLunisolarYear.MonthCount; the Hebrew arm already answeredMonthCodeToOrdinalGetDaysInMonthCalAlgorithmicDaysInMonth— the reckoning's month length,HebrewDaysInMonthOrdinal,PersianAlgorithmicDaysInMonth. It used to answer a flat 30cal.ToDateTimeBuiltinCalendarDateToIso, the sameswitchCalendarDateToIsoruns, whose arms already reckon past their tablesBuiltinCalendarDateToIsorather thanCalendarDateToIso: the latter has a last-resort arm that answersan unplaceable date with its fields read as ISO, which is another calendar's answer wearing this one's
name — and, worse for the walk below, an answer whose progress says nothing about this calendar's.
All eleven non-ISO calendars now measure and add across the whole of Temporal's range.
The walk still terminates, and here is why
#3452's guarantee is the one thing that may not regress:
CalendarDateUntilwalks a month at a time andits only exit is "this step passed the target", so an answer that does not progress is a hang no
execution constraint can interrupt (#3428). Three things keep it bounded, in that order:
LunisolarAlgorithmicToIsoanswersMonthStarts[ordinal - 1] + day - 1,HebrewAlgorithmicToIsoandPersianAlgorithmicToIsosum monthlengths from a year start, and
LunisolarAstronomy.BuildfillsMonthStartsfrom a strictlyincreasing new-moon sequence. Every step therefore moves.
LunisolarAstronomy.ForYearreturns null outsideISO −271821…275760,
BuiltinCalendarDateToIsoreturns null, andCalendarRangeExceptionis raised —still a
RangeError, still catchable. That is the caseTheRefusalPastTemporalsOwnRangeIsAJavaScriptErrorAScriptCanCatchnow covers.RangeError, which isthe structural guarantee against a reckoning that saturates rather than progressing — including a host
ICalendarProvider, which is still free to.Every case in
NonIsoCalendarRangeTestsandNonIsoCalendarArithmeticRangeTestsruns on a dedicatedthread with a 15-second join, so a regression fails the run rather than wedging it, and both sweep to
±100,000 years.
Terminating was not enough: what a walk that is now taken costs
CI found this and it is the more interesting half. A difference these four calendars used to refuse is now
walked, and the first version of this branch took longer than 15 seconds on the GitHub runners to
measure 250 years of
chineseby month. Two changes, and the second is the one that mattered:holds twelve months or thirteen, averaging 12.37, so starting from a thirteen-month year and estimating
a millennium overshoots by some 625 months — every one of which the back-off loop then stepped off one
at a time. It is now the average month length the same conversion reports,
DaysInYear / MonthsInYear,which does not drift; the two correction loops still decide the answer, so the estimate only decides how
long the walk is.
LunisolarAstronomykeeps a two-tier cache of built years — a[ThreadStatic]most-recent entry,which is what makes reading one date's six fields cost one build rather than six, and a shared 256-entry
direct-mapped table published with
Volatile.Write/Volatile.Read. ALunisolarYearis a pure functionof its year and region, so sharing it is safe and a slot lost to a race costs a rebuild and nothing else.
until(..., { largestUnit: 'month' })chinese2050 → 2300 (250 y)dangi2050 → 2300hebrew2050 → 2300chinese1990 → 1000 (990 y)What is left is that
add/subtractof months in bulk is still linear in the years crossed for the threecalendars whose years hold a varying number of months, so
add({ months: 3000000 })is seconds ofuninterruptible work. The six closed-form calendars answer the same query in ~100 ms and always could;
follow-up filed as #3511.
Failing-test evidence
Jint.Tests/Runtime/NonIsoCalendarArithmeticRangeTests.csagainst unfixedmain:On
main, for the dates whose fields it answers:The pinned refusals this deliberately updates
NonIsoCalendarRangeTestspinned the #3452 behaviour, so 15 of its cases now assert the answer instead ofthe refusal — which the issue names as the deliberate change. What that file is for has not moved: the
dedicated thread, the join timeout, the sweep over every difference surface that walks months, and the
largestUnit-by-opmatrix all stay, and each case now also asserts thata.add(a.until(b))isbanda.subtract(a.since(b))isb— a walk that stops in the right place, not merely one that stops.ArithmeticInsideEveryCalendarRangeIsUnchangedpasses onmainand after: nothing inside a table moves.Testing
dotnet build -c Releaseon the solution — clean, bar the one pre-existingMSB3277inJint.Tests.CommonScriptsonnet472.Jint.Tests— net472 7,707 / net8.0 11,086 / net10.0 11,087 passed; the onenet8.0failure wasWpt.RunsTheFetchRedirectSuite, unrelated to calendars, and passes in isolation.Jint.Tests.PublicInterface— net472 2,679 / net8.0 3,302 / net10.0 3,312 passed, 0 failed.Jint.Tests.CommonScripts28 on each of two TFMs,Jint.Tests.SourceGenerators71 — 0 failed.Jint.Tests.Test262— 102,521 passed, 165 skipped, plus the twostaging/sm/Array/toSpliced-dense30-second timeouts that are the known under-load flake and pass in isolation (182 passed, 0 failed).
102,521 + 2 = 102,523, which is the control on this base measured with nothing applied.
Migration guide: §4.70, and §4.50's "arithmetic past a table's end still refuses" bullet now points at it.
🤖 Generated with Claude Code
https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S