Skip to content

Temporal: a persian year the .NET table stops inside is measured by the calendar - #3528

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:temporal-persian-table-edge
Aug 31, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:temporal-persian-table-edge

Conversation

@lahma

@lahma lahma commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3523. Present on main today at 2255b5c9 — #3525 landed after the issue was filed and touched the
one site the issue names, and reproduces it unchanged.

What is wrong

PersianCalendar ends at ISO 9999-12-31 because DateTime does, not because the Persian calendar does, and
that date falls inside Persian year 9378. Asked about that year the table answers with the part it holds —
ten months, 289 days, and a tenth month thirteen days long — and answers consistently: GetMonthsInYear
says 10, GetDaysInYear says 289, GetDaysInMonth(9378, 10) says 13 and GetDayOfYear(MaxSupportedDateTime)
says 289. Nothing the table says contradicts anything else it says, which is why a truncated year is invisible
from inside it.

A month step that stopped inside that tenth month therefore had its day clamped to the thirteenth and
carried the loss forward, while a step that flew over the month kept it:

const p = Temporal.PlainDate.from('9999-06-01').withCalendar('persian');   // 9378-M03-14

p.add({ months: 8 });                                              // +010000-02-01
let d = p; for (let i = 0; i < 8; i++) { d = d.add({ months: 1 }); }  // +010000-01-31

Steps +1 to +7 agree; the two spellings part at +8 and stay a day apart after it. Swept day by day over the
last thirty years the table covers and the first thirty past it, 2,496 of 560,976 additions disagreed, and
the stepped route collapses hundreds of distinct starts onto the same answer:

9998-12-31 + 13 months: 10000-02-01 in one go, 10000-01-31 one at a time
9999-01-01 + 13 months: 10000-02-02 in one go, 10000-01-31 one at a time
9999-01-02 + 13 months: 10000-02-03 in one go, 10000-01-31 one at a time
…

until was collateral: a difference measured across the seam came back mixed-sign, which no Temporal
duration may be — persian from ISO +010000-02-16 back to +010000-01-07 answered -1 month and +7 days,
because the intermediate the month walk left off at had been clamped onto 9999-12-31.

The fix

The band a backing Calendar is trusted for now covers only the years its table holds whole. #3525's
SupportsYear is still the one site that decides which reckoning answers for a year; YearBand.Of now trims
a boundary year off the band when the table cannot state a length for the last month that year holds — asked
of the calendar's own reckoning, which is the only thing the table's self-consistent account can be checked
against. So Persian 9378's month count and month lengths come from the same arithmetic reckoning that already
answered for every year past it, and its tenth month is thirty days long.

The issue's other option — asking the table how many months a year holds — was rejected: it would have made
9378 a ten-month year, so a month step would skip its eleventh and twelfth months entirely, some fifty-nine
days at a stride.

Where a date sits is not what this decides. The table placed every ISO date it covers and still does, so
the placement in PersianDateToIso still tries ToDateTime first and falls to the reckoning only for a date
the table has no room for. That is what keeps fields → ISO → fields the identity, which is the property
additivity actually needs, and it is why no date moved.

The one other change is that PersianToCalendarDate no longer reports the table's daysInMonth and
daysInYear for a year it stops inside — a date whose fields the engine reports has to be a date whose
arithmetic the engine performs (#3483), and 289 days in a twelve-month year was neither.

Hebrew and Um al-Qura both end where their data ends, on a year boundary, and the trim is a no-op for them:
Hebrew 5343 begins before MinSupportedDateTime but still answers with twelve months the table can measure,
so it stays in the band.

What changed, measured

Every observable answer was dumped before and after, on net8.0, and diffed:

cases lines that differ
field reads (7 calendars, daily across each band edge) 417,136 578 — all persian, all in 9378
add (years × months × overflow) 14,517,516 1,260 — all persian, all landing in 9378-M10
fields → ISO (constrain and reject, boundary years) 22,568 72 — persian 9378-M10 days 14–31
until (4 largest units, 8 spans) 580,544 30 — 5 persian differences at the seam

Everything that differs is inside Persian 9378 and nowhere else:

  • no date moved — year, ordinal month, monthCode, day, leap-month and leap-year came back identical on
    every one of the 417,136 field reads, including all 289 in 9378;
  • daysInMonth in its tenth month is 30 rather than 13 and daysInYear anywhere in it is 365 rather than 289
    (ISO 9999-03-18 to 9999-12-31);
  • days 14 to 30 of that tenth month are dates now — they used to constrain onto ISO 9999-12-31 and to be
    rejected under overflow: 'reject', and now place at ISO +010000-01-02 through +010000-01-18;
  • the five until differences all lose the mixed sign described above.

No other calendar's answers changed: hebrew, islamic-umalqura, islamic-civil, chinese, coptic and
indian are byte-identical across all four dumps.

Tests

Jint.Tests/Runtime/CalendarBandEdgeTests.cs, which fails on unmodified main in four of its fourteen cases
and passes here:

  • the issue's own repro;
  • the seam sweep — every ISO day across the last thirty in-band years and the first thirty out-of-band
    ones, stepped one month at a time and in one go, forwards and backwards, thirteen months each way.
    persian: 560,976 cases, 2,496 disagreeing on main, none here. The Hebrew band edges get the identical
    sweep — 578,214 cases at the bottom and 578,188 at the top — and report nothing before or after, which is
    what says the shape does not hide there. Temporal: adding months in hebrew counts the Metonic cycle #3525's sweep stepped by 37 days and could step over a seam one day
    wide; this one is dense on purpose. Only starts on day 29 or earlier are asserted, since a start above that
    legitimately diverges in every calendar (ISO 2024-01-31 plus two months is March 31, and one month twice is
    March 29);
  • a.add(a.until(b)) reaching b across each edge, in all four largest units;
  • the partial year's fields, and the whole year before it still being read from the table — 9377 is a leap
    year of 366 days there and a common year of 365 by the reckoning, so the assertion says which one answered.

Jint.Tests is green on net472, net8.0 and net10.0; Jint.Tests.PublicInterface, Jint.Tests.CommonScripts
and Jint.Tests.SourceGenerators too. test262: 102,537 passed / 0 failed / 151 skipped of 102,688, the
control figure exactly.

No benchmark is offered and none would say anything: the change is a comparison against two cached ints on a
path that already made one.

Migration guide

§4.87, at the end of chapter 4.

🤖 Generated with Claude Code

https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

…he calendar

PersianCalendar ends at ISO 9999-12-31 because DateTime does, not because the
Persian calendar does, and that date falls inside Persian 9378. Asked about that
year the table answers with the part it holds -- ten months, 289 days, a tenth
month thirteen days long -- and answers consistently, so nothing it says gives
the truncation away.

A month step that stopped inside that tenth month had its day clamped to the
thirteenth and carried the loss forward, while a step that flew over the month
kept it, so add({ months: 8 }) and eight single steps from ISO 9999-06-01 landed
a day apart.

The band a backing Calendar is trusted for now covers only the years its table
holds whole, so 9378's month count and month lengths come from the arithmetic
reckoning that already answered for every year past it. Where a date sits is
unchanged: the table placed every ISO date it covers and still does.

Fixes sebastienros#3523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the temporal-persian-table-edge branch from 6162a04 to 1e195b9 Compare August 31, 2026 18:49
@lahma
lahma merged commit 32989e8 into sebastienros:main Aug 31, 2026
7 checks passed
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>
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.

Temporal: a persian month addition is not additive at the end of the BCL table

1 participant