Skip to content

Temporal: chinese and dangi read the same on every target framework - #3507

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:temporal-lunisolar-one-answer-per-runtime
Aug 30, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:temporal-lunisolar-one-answer-per-runtime

Conversation

@lahma

@lahma lahma commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3484. Stacked on #3502 — its commit is the first of the two here, so merge that one first.

What was wrong

chinese and dangi were read from System.Globalization.ChineseLunisolarCalendar and
KoreanLunisolarCalendar, and those are not the same table on every runtime. One script, three
answers:

Temporal.PlainDate.from('1500-06-15').withCalendar('dangi').day;
// net472: 19      net8.0: 9       net10.0: 9

Temporal.PlainDate.from('2057-09-28').withCalendar('chinese').monthCode;
// net472: "M08"   net8.0: "M09"   net10.0: "M08"

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 dangi table by 2,218 days over 1654–2051 while matching it exactly over
1912–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 Kong
Observatory'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.

source, chinese 1901–2100 wrong month boundaries misplaced leap months
ChineseLunisolarCalendar on .NET 8 0 0
the reckoning in this PR 1 (1906-04-24) 0
ChineseLunisolarCalendar on .NET 10 1 (2057-09-28) 0
ChineseLunisolarCalendar on .NET Framework 4.8 3 0
ICU 78 (Node 24) 9 3

The 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 dangi this needs no authority at
all. 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:

source, dangi 918–1600 (8,447 month starts) more than 2 days from a new moon median offset
ICU 78 0 +0.17 d
the reckoning in this PR 0 +0.18 d
KoreanLunisolarCalendar on .NET 8 / .NET 10 2 +0.05 d
KoreanLunisolarCalendar on .NET Framework 4.8 8,225 +7.18 d

.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 dangi must equal
chinese because 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 dangi was 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

  • NonIsoCalendars no longer instantiates either lunisolar Calendar. GetCalendar answers null for
    chinese/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.
  • LunisolarToCalendarDate and the dual-path LunisolarDateToIso are gone; one conversion each way.
  • PlaceOrdinalMonth replaces the three cal.ToDateTime calls in PlainMonthDay's reference-year search,
    which now compares epoch days rather than DateTime.Ticks. For the calendars that still have a backing
    Calendar it is the identical call, so nothing there moves.
  • Intl.DateTimeFormat reads the same conversion. It used to read the tables directly and clamp a
    date outside them to the table's own first or last date and format that — 1800-01-01 in chinese
    printed as the Chinese new year of 1901. It now prints the date asked for and agrees with Temporal
    field for field. The sexagenary year name is computed rather than read off GetSexagenaryYear, from the
    same 1984 = 甲子 anchor.
  • The reckoning's two-tier year cache comes from Temporal: a date a calendar reports fields for is a date it reckons arithmetic in #3502, which needed it for the same reason: without
    the shared 256-entry tier, PlainMonthDay's ±75-year reference-year sweep rebuilt every year on
    every call and eleven intl402/Temporal/PlainMonthDay test262 files timed out at 30 s. With it they
    pass in under a second.

Failing-test evidence

Jint.Tests/Runtime/LunisolarCalendarAgreementTests.cs against unfixed main, and the point is that
it fails differently on each runtime:

net472    Failed! - Failed: 12, Passed: 15, Total: 27
  Failed chinese, the ninth month of 2057            expected 2057|M09|1, got 2057|M08|30
  Failed chinese, the eighth month of 2089           expected 2089|M08|1, got 2089|M07|30
  Failed chinese, the seventh month of 2097          expected 2097|M07|1, got 2097|M06|30
  Failed dangi, the seventh month of 1000            expected 1000|M07|1,  got 1000|M07|6
  Failed dangi, the second month of 1200             expected 1200|M02|8,  got 1200|M02|15
  Failed dangi, the fifth month of 1400              expected 1400|M05|14, got 1400|M05|23
  Failed dangi, the fifth month of 1500              expected 1500|M05|9,  got 1500|M05|19
  Failed EveryMonthBeginsAtANewMoon("dangi")
  Failed IntlNamesTheSameDayAsTemporal ×4

net8.0    Failed! - Failed:  4, Passed: 23, Total: 27
  Failed IntlNamesTheSameDayAsTemporal ×4

net10.0   Failed! - Failed:  5, Passed: 22, Total: 27
  Failed chinese, the ninth month of 2057
  Failed IntlNamesTheSameDayAsTemporal ×4

After: 27 passed on all three.

Every expectation in that file comes from outside the engine — the HKO table for chinese, the Chinese
calendar of the same years for pre-1912 dangi (corroborated by ICU and by .NET Core's table), and
EveryMonthBeginsAtANewMoon from the mean synodic month, which needs no reference at all.

NonIsoCalendarOutOfRangeFieldTests.ADateInsideTheTableIsStillAnsweredByTheTable is replaced rather than
updated: 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 these
dates nor the year cache #3502 adds:

workload net472 net8.0 net10.0
warm, 1750–1850 — reckoned on main too 28.78 → 1.34 10.40 → 1.38 10.38 → 1.13
warm, 1950–2050 — the BCL table on main 1.77 → 1.27 0.99 → 1.23 1.16 → 1.19
warm, one year read 100× 1.64 → 1.26 0.60 → 1.07 0.69 → 1.22
cold, 100 never-built years, one pass (JIT included) 471 → 437 281 → 243 305 → 240

The 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
main produces no signal at all above that spread.

Testing

  • dotnet build -c Release on the solution — clean, bar the one pre-existing MSB3277 in
    Jint.Tests.CommonScripts on net472.
  • 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.CommonScripts 28 on each of two TFMs, Jint.Tests.SourceGenerators 71 — 0 failed.
  • Jint.Tests.Test262 — 102,519 passed, 165 skipped, plus four known under-load flakes: three
    staging/sm 30-second timeouts and one Atomics/notify. All four pass in isolation (179 passed, 0
    failed), 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

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
lahma force-pushed the temporal-lunisolar-one-answer-per-runtime branch from 266113d to 72cb6ad Compare August 30, 2026 16:03
@lahma
lahma merged commit eb3bf01 into sebastienros:main Aug 30, 2026
7 checks 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.

Temporal: a chinese or dangi date reads differently on net472, net8.0 and net10.0

1 participant