Skip to content

ICU: patch the Chinese calendar to the HKO month starts and leap months for 1901-2100 - #610

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/3001a73a/icu-chinese-calendar-hko
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/3001a73a/icu-chinese-calendar-hko

Conversation

@robobun

@robobun robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With the bundled ICU 78.3, Temporal.PlainDate.from("1987-08-01").withCalendar("chinese").monthCode is "M07" and { year: 1987, monthCode: "M06L" } rejects. The Hong Kong Observatory tables, Apple's ICU (so Bun on macOS) and ICU4X (V8, Chromium 148) have 1987 闰六月: M06L from 1987-07-26. Same for 1917 (M02L) and 1922 (M05L).
  • The cause is ICU4C's astronomical ChineseCalendar (chnsecal.cpp): twelve events between 1901 and 2101 fall within minutes of midnight UTC+8 and CalendarAstronomer puts them on the wrong day. Besides the three leap months, nine month starts are a day off, among them Chinese New Year 1954 (Feb 3, ICU says Feb 4), 2027 (Feb 6, ICU says Feb 7) and 2030 (Feb 3, ICU says Feb 2). Intl.DateTimeFormat with calendar: "chinese" shows the same dates, so Linux and Windows disagree with macOS today.

Fix

  • icu/chinese-calendar-hko.patch, applied next to udata-decompress-hook.patch in every image that builds ICU (glibc, musl, Windows cross, Android, FreeBSD). It is the chnsecal.cpp part of ICU-22230 ICU-23198 ICU-23286 Fix Chinese Calendar leap month boundary and far-future precision unicode-org/icu#4070 (ICU-23274, accepted upstream, not yet released) with two fixes.
  • Fix 1: the corrections apply only in the Chinese calendar's astronomical zone. Upstream keys them on day numbers inside helpers that DangiCalendar shares, which moves six Korean month starts away from the KASI tables (for example 설날 1954 from Feb 4 to Feb 3).
  • Fix 2: a corrected new moon keeps the search contract (last new moon before the bound day, or first one on or after it). Upstream bumps the three "computed a day early" conjunctions forward even in a backward search from that day, so 2018-11-07, 2030-02-02 and 2101-06-26 came out as day 0 of the next month.
  • Verified with a local jsc linked against ICU 78.3 plus this patch: every lunation 1900-2102 matches ICU4X's china_data.rs/qing_data.rs except 2057-09-28/29 (left as computed, see Notes), korea_data.rs differences are the same three as without the patch, a per-day walk over 1900-2101 finds no field inconsistency or round-trip failure for chinese and dangi, and test262 intl402/Temporal passes 2029/2029 (the */monthCode/chinese-calendar-dates.js and */daysInYear/basic-chinese.js entries in expectations-linux.yaml pass with it).

Background

  • The Chinese calendar starts a month on the day (UTC+8) of each new moon and numbers months by the principal solar terms (every 30 degrees of solar longitude) they contain. A month without a principal term is a leap month and repeats the previous number. An event a few minutes from midnight decides a whole month's dates or which month is the leap one.
  • ICU4C computes new moons and solar longitudes with CalendarAstronomer, a low-precision model. ICU4X ships precomputed tables for 1900-2100 that match the Hong Kong Observatory and Purple Mountain Observatory publications. Apple's ICU has its own adjustment tables. JSC's Temporal bridges to ICU4C through ucal_*, so it inherits whichever ICU the build links.
  • newMoonNear(days, after) is the primitive everything else uses: month start for a date (backward search from the next day), next month (forward search 25 days after a new moon), new year. A correction that breaks its before/after contract shows up as day 0 or a skipped month, which is what the two fixes guard.
Notes
  • Found by a differential run of Bun 1.4.2 against Node 26.3 and Chromium 148 (fuzz ledger Remove confusing copyFromExternalTexture() variants from BitmapTexture WebKit/WebKit#45363). The full list of divergent chinese years against ICU4X before this patch: 1917, 1922, 1953/1954, 1955, 1987, 1998, 2012, 2018, 2026/2027, 2029/2030, 2057, 2070, 2101. After: 2057 only.
  • 2057-09-28/29: the conjunction is predicted for 2057-09-29 00:00:40 UTC+8 (Y.T. Liu), so the month start depends on seconds of Delta-T. ICU4X's table says the 28th, the upstream ICU test data and the HKO PDF say the 29th. ICU4C computes the 29th and this patch leaves it.
  • Dangi still differs from ICU4X in 2017-02, 2051 and 2096/2097, as before. KASI publishes data through 2050, so only 2017 is a real divergence, and it is not touched here.
  • macOS links the system libicucore, which already has Apple's tables, so nothing changes there. Apple's ICU returns month 9 day 0 for 2057-09-28, which is the same contract break as Fix 2, in their table code.
  • build-icu.ps1 (local Windows WebKit builds) does not apply udata-decompress-hook.patch either and is left alone.
  • The scripts used for the verification (ICU4X table comparison, per-day walk) are in the PR conversation on request; they need a jsc with --useTemporal=1.

…hs for 1901-2100

The bundled ICU 78.3 computes the Chinese calendar astronomically. Nine new
moons and three principal solar terms between 1901 and 2101 fall within
minutes of midnight UTC+8, and CalendarAstronomer lands them on the wrong day.
So Temporal and Intl.DateTimeFormat with calendar "chinese" put Chinese New
Year 1954, 2027 and 2030 a day off, shift five other month starts by a day,
and place the leap months of 1917, 1922 and 1987 one lunation late, compared
with the Hong Kong Observatory tables that Apple's ICU (Bun on macOS) and
ICU4X (V8) follow.

icu/chinese-calendar-hko.patch carries the chnsecal.cpp part of
unicode-org/icu#4070 (ICU-23274) with two fixes: the corrections are limited
to the Chinese calendar's astronomical zone so the Dangi calendar is
unchanged, and a corrected new moon keeps the before/after contract of the
search so no date resolves to day 0. It is applied next to the existing
udata patch in every image that builds ICU.
@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Verification scripts used for the numbers in the description. Both run under a jsc built with --useTemporal=1 support (or under Bun/Node for the stock-ICU baseline).

gen-compare-jsc.mjs: builds compare-icu4x-jsc.js from ICU4X's china_data.rs, qing_data.rs and korea_data.rs (components/calendar/src/cal/east_asian_traditional/), then prints every year whose month codes, month starts or month lengths differ
// Generates /tmp/compare-icu4x-jsc.js: a self-contained script (print-based) that compares the
// engine's chinese/dangi calendar months against ICU4X's precomputed tables.
import { readFileSync, writeFileSync } from "node:fs";

function parseTable(file) {
  const src = readFileSync(file, "utf8");
  const re = /PackedEastAsianTraditionalYearData::new\((\d+), \[([ls, ]+)\], (None|Some\((\d+)\)), gregorian\((\d+), (\d+), (\d+)\)\)/g;
  const rows = [];
  let m;
  while ((m = re.exec(src))) {
    rows.push({ year: +m[1], lens: m[2].split(",").map(s => s.trim()).filter(Boolean).map(s => (s === "l" ? 30 : 29)), leap: m[3] === "None" ? null : +m[4], ny: [+m[5], +m[6], +m[7]] });
  }
  return rows;
}

const data = {
  chinese: [...parseTable("/tmp/qing_data.rs"), ...parseTable("/tmp/china_data.rs")],
  dangi: parseTable("/tmp/korea_data.rs"),
};

const body = `
const DATA = ${JSON.stringify(data)};
const out = typeof print === "function" ? print : console.log;
function expectedMonths(row) {
  const n = row.leap ? 13 : 12;
  let start = Temporal.PlainDate.from({ year: row.ny[0], month: row.ny[1], day: row.ny[2] });
  const parts = [];
  let num = 0;
  for (let ord = 1; ord <= n; ord++) {
    let code;
    if (row.leap && ord === row.leap) code = "M" + String(num).padStart(2, "0") + "L";
    else { num++; code = "M" + String(num).padStart(2, "0"); }
    parts.push(code + "@" + start.toString() + "(" + row.lens[ord - 1] + ")");
    start = start.add({ days: row.lens[ord - 1] });
  }
  return parts.join(" ");
}
function actualMonths(calendar, year) {
  const d = Temporal.PlainDate.from({ year, month: 1, day: 1, calendar });
  const parts = [];
  let m = d;
  for (let k = 0; k < d.monthsInYear; k++) {
    parts.push(m.monthCode + "@" + m.withCalendar("iso8601").toString() + "(" + m.daysInMonth + ")");
    m = m.add("P1M");
  }
  return parts.join(" ");
}
for (const calendar of ["chinese", "dangi"]) {
  let diffs = 0;
  for (const row of DATA[calendar]) {
    if (row.year > 2102) continue;
    const e = expectedMonths(row);
    let a;
    try { a = actualMonths(calendar, row.year); } catch (err) { a = "THROW " + err.message; }
    if (e !== a) { diffs++; out(calendar + " " + row.year + "\\n  icu4x: " + e + "\\n  here : " + a); }
  }
  out(calendar + ": " + DATA[calendar].length + " years compared, " + diffs + " differ");
}
`;
writeFileSync("/tmp/compare-icu4x-jsc.js", body);
console.log("wrote /tmp/compare-icu4x-jsc.js");
chinese-consistency.js: per-day walk 1900-01-01 to 2101-12-31 for chinese and dangi (day sequence, daysInMonth, dayOfYear, daysInYear, monthsInYear, inLeapYear, round trip from fields)
// Per-day internal consistency of the chinese (and dangi) calendar through Temporal, 1900-01-01 .. 2101-12-31.
const out = typeof print === "function" ? print : console.log;
for (const calendar of ["chinese", "dangi"]) {
  let d = Temporal.PlainDate.from("1900-01-01");
  const end = Temporal.PlainDate.from("2102-01-01");
  let prev = null;
  let problems = 0;
  let yearDays = 0, yearLengthClaim = 0, yearStartIso = null;
  const report = (msg) => { if (problems++ < 40) out(`${calendar} ${msg}`); };
  while (Temporal.PlainDate.compare(d, end) < 0) {
    let c;
    try {
      c = d.withCalendar(calendar);
      const f = { year: c.year, monthCode: c.monthCode, day: c.day, dim: c.daysInMonth, doy: c.dayOfYear, diy: c.daysInYear, miy: c.monthsInYear, leap: c.inLeapYear };
      if (f.day < 1 || f.day > f.dim) report(`${d} day ${f.day} outside 1..${f.dim} (${f.monthCode})`);
      if (prev) {
        const sameMonth = prev.year === f.year && prev.monthCode === f.monthCode;
        if (sameMonth) {
          if (f.day !== prev.day + 1) report(`${d} day jumps ${prev.day} -> ${f.day} inside ${f.monthCode}`);
          if (f.dim !== prev.dim) report(`${d} daysInMonth changes inside month ${prev.dim} -> ${f.dim}`);
        } else {
          if (f.day !== 1) report(`${d} new month ${f.monthCode} starts at day ${f.day}`);
          if (prev.day !== prev.dim) report(`${d.subtract({ days: 1 })} month ${prev.monthCode} ends at day ${prev.day} of ${prev.dim}`);
        }
        if (prev.year === f.year) {
          if (f.doy !== prev.doy + 1) report(`${d} dayOfYear jumps ${prev.doy} -> ${f.doy}`);
          if (f.diy !== prev.diy || f.miy !== prev.miy || f.leap !== prev.leap) report(`${d} year-level fields change inside year ${f.year}`);
        } else {
          if (f.doy !== 1) report(`${d} new year ${f.year} starts at dayOfYear ${f.doy}`);
          if (prev.doy !== prev.diy) report(`${d.subtract({ days: 1 })} year ${prev.year} ends at dayOfYear ${prev.doy} of ${prev.diy}`);
          if ((f.miy === 13) !== f.leap) report(`${d} monthsInYear ${f.miy} but inLeapYear ${f.leap}`);
        }
      }
      // Round trip from fields.
      const back = Temporal.PlainDate.from({ year: f.year, monthCode: f.monthCode, day: f.day, calendar }, { overflow: "reject" }).withCalendar("iso8601");
      if (!back.equals(d)) report(`${d} -> ${f.year} ${f.monthCode} ${f.day} -> ${back} does not round-trip`);
      prev = f;
    } catch (e) {
      report(`${d} THROW ${e.message}`);
      prev = null;
    }
    d = d.add({ days: 1 });
  }
  out(`${calendar}: ${problems} problems over 1900-2101`);
}

Results with ICU 78.3 + this patch: chinese: 203 years compared, 1 differ (2057), dangi: 191 years compared, 3 differ (2017, 2051, 2096, same as stock 78.3), chinese: 0 problems over 1900-2101, dangi: 0 problems over 1900-2101. With upstream unicode-org/icu#4070 applied verbatim instead: chinese differs in 2018, 2029, 2057, 2101 (day 0 on 2018-11-07, 2030-02-02, 2101-06-26) and dangi differs in 11 years.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
9a7bc4df autobuild-preview-pr-610-9a7bc4df 2026-09-09 00:44:57 UTC

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.

2 participants