Temporal: chinese and dangi read the same on every target framework - #3507
Merged
lahma merged 1 commit intoAug 30, 2026
Merged
Conversation
lahma
force-pushed
the
temporal-lunisolar-one-answer-per-runtime
branch
from
August 30, 2026 15:32
55acb7d to
2036a6c
Compare
lahma
force-pushed
the
temporal-lunisolar-one-answer-per-runtime
branch
from
August 30, 2026 15:51
2036a6c to
266113d
Compare
Fixes sebastienros#3484. The two calendars were read from System.Globalization.ChineseLunisolarCalendar and KoreanLunisolarCalendar, and those are not the same table on every runtime, so one script gave three answers: 1500-06-15 in dangi is day 19 on net472 and day 9 on .NET Core, and 2057-09-28 in chinese is M09 day 1 on .NET 8 and M08 day 30 on net472 and .NET 10. Both are now reckoned by the astronomical implementation sebastienros#3482 added for the dates past the end of those tables, for every date rather than only for those. It is the same code on every runtime, and measured against the Hong Kong Observatory's published conversion table over 1901-2100 it names one of 2,473 month boundaries differently, where .NET 8's table names none, .NET 10's one, .NET Framework's three, and ICU fifteen. dangi from 1912 does not move at all on any runtime. Intl.DateTimeFormat reads the same reckoning now, so it agrees with Temporal field for field, and a date outside the retired tables is no longer clamped to the table's own first or last date. The year cache behind the reckoning is two-tier and process-wide, sized to hold the +/-75-year window PlainMonthDay's reference-year search sweeps: steady-state field reads cost about what the table lookup did, and dates that were already reckoned are 7-21x faster. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
force-pushed
the
temporal-lunisolar-one-answer-per-runtime
branch
from
August 30, 2026 16:03
266113d to
72cb6ad
Compare
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 #3484. Stacked on #3502 — its commit is the first of the two here, so merge that one first.
What was wrong
chineseanddangiwere read fromSystem.Globalization.ChineseLunisolarCalendarandKoreanLunisolarCalendar, and those are not the same table on every runtime. One script, threeanswers:
Nothing tested it, because every test compared the engine against whichever of those tables the runtime it
was executing on happened to carry — so all three looked right.
The investigation, because the obvious fix might have been wrong
The issue proposed replacing the tables with the astronomical reckoning #3482 added, and warned that the
reckoning disagrees with the
dangitable by 2,218 days over 1654–2051 while matching it exactly over1912–2051 — which could mean the table encodes real Korean history that a modern reckoning cannot
reproduce. So the tables were measured against sources outside .NET before anything was changed.
1. Which runtime matches the authority? ICU is what every other JavaScript engine reckons these
calendars with, and it is not a reliable reference for
chinese: measured against the Hong KongObservatory's published Gregorian–Lunar Calendar Conversion Table over 1901–2100 (2,473 months), ICU 78
names nine month boundaries differently and puts the leap month of 1917, 1922 and 1987 on the wrong
month — it reads 1987-07-26 as month 7 where HKO reads it as the leap sixth month, 闰六月, which is what
the Chinese calendar of 1987 has.
chinese1901–2100ChineseLunisolarCalendaron .NET 8ChineseLunisolarCalendaron .NET 10ChineseLunisolarCalendaron .NET Framework 4.8The reckoning's single divergence is the pre-1929 meridian: China reckoned its calendar at Beijing local
mean time until 1929, which the reckoning models and ICU does not. Reckoning 1901–1928 at UTC+8 instead was
measured — it moves the 1906 boundary onto HKO's and moves three others off it (1914, 1916, 1920), so
the meridian change is kept.
2. Where the tables disagree with each other, which is right? For
dangithis needs no authority atall. A lunisolar month begins on the day of a new moon, so every month start has to sit within a day or so
of one. Over 918–1600, taking the mean new moon as the reference:
dangi918–1600 (8,447 month starts)KoreanLunisolarCalendaron .NET 8 / .NET 10KoreanLunisolarCalendaron .NET Framework 4.8.NET Framework's copy is not a lunisolar calendar over those centuries: it puts day 1 of a month around
first quarter. It is not "different history" — 1500-06-15 reads as 1500/5/19 there, which places the month
start ten days before the conjunction. Its modern half is fine: over 1901–1911, where
dangimust equalchinesebecause Korea reckoned China's calendar, all three tables and ICU agree with HKO exactly.3. So what is defensible? Reckoning everywhere. It is the only option that gives one answer; it is at
least as accurate as any of the three tables against the one authority that covers the disputed years; it
reproduces the modern Korean calendar exactly (1,720 months, 1912–2051, zero differences on all three
runtimes); and it repairs a runtime whose
dangiwas not a lunisolar calendar at all before ~1550.The cost is real and is stated in the migration guide: before 1912, .NET Core's Korean table records a
calendar computed by pre-modern methods — its divergence from any modern computation jumps from ~8% to
~45% below 1281, which is where the Shoushi reform of 1281 sits, so it is plausibly a historical record —
and that is replaced by modern rules applied backwards. No other Temporal implementation preserves it
either; ICU carries no such table.
What changed
NonIsoCalendarsno longer instantiates either lunisolarCalendar.GetCalendaranswers null forchinese/dangi, and the year's month count, its leap month, a month's length and where a resolved(year, ordinal, day) lands all come from
LunisolarAstronomy.LunisolarToCalendarDateand the dual-pathLunisolarDateToIsoare gone; one conversion each way.PlaceOrdinalMonthreplaces the threecal.ToDateTimecalls inPlainMonthDay's reference-year search,which now compares epoch days rather than
DateTime.Ticks. For the calendars that still have a backingCalendarit is the identical call, so nothing there moves.Intl.DateTimeFormatreads the same conversion. It used to read the tables directly and clamp adate outside them to the table's own first or last date and format that —
1800-01-01inchineseprinted as the Chinese new year of 1901. It now prints the date asked for and agrees with
Temporalfield for field. The sexagenary year name is computed rather than read off
GetSexagenaryYear, from thesame 1984 = 甲子 anchor.
the shared 256-entry tier,
PlainMonthDay's ±75-year reference-year sweep rebuilt every year onevery call and eleven
intl402/Temporal/PlainMonthDaytest262 files timed out at 30 s. With it theypass in under a second.
Failing-test evidence
Jint.Tests/Runtime/LunisolarCalendarAgreementTests.csagainst unfixedmain, and the point is thatit fails differently on each runtime:
After: 27 passed on all three.
Every expectation in that file comes from outside the engine — the HKO table for
chinese, the Chinesecalendar of the same years for pre-1912
dangi(corroborated by ICU and by .NET Core's table), andEveryMonthBeginsAtANewMoonfrom the mean synodic month, which needs no reference at all.NonIsoCalendarOutOfRangeFieldTests.ADateInsideTheTableIsStillAnsweredByTheTableis replaced rather thanupdated: comparing the engine to the runtime's own table is exactly the practice that let this hide. What
takes its place sweeps the same span for the properties any lunisolar calendar has, and for the field
round-trip.
Performance
100 dates, six calendar fields read off each, milliseconds per pass, median of five, against plain
main— which is what an embedder actually moves from, and which had neither the reckoning for thesedates nor the year cache #3502 adds:
maintoomainThe one solid signal is the first row: dates the engine already reckoned are 7 to 21× faster, because the
cache serves what the single memo entry could not. The rest sits inside this machine's run-to-run spread —
repeating either tree moves those rows by up to 2.4× — so the honest reading is that a steady-state field
read costs about what the table lookup did, and measuring this branch against #3502 rather than against
mainproduces no signal at all above that spread.Testing
dotnet build -c Releaseon the solution — clean, bar the one pre-existingMSB3277inJint.Tests.CommonScriptsonnet472.Jint.Tests— net472 7,734 / net8.0 11,114 / net10.0 11,114 passed, 0 failed.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,519 passed, 165 skipped, plus four known under-load flakes: threestaging/sm30-second timeouts and oneAtomics/notify. All four pass in isolation (179 passed, 0failed), and 102,519 + 4 = 102,523, which is the control measured on this base with nothing applied.
Migration guide: §4.71, and §4.50's "it is a fallback, not a replacement" now points at it.
🤖 Generated with Claude Code
https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S