Conversation
…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.
Collaborator
Author
|
Verification scripts used for the numbers in the description. Both run under a 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: |
Preview Builds
|
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.
Problem
Temporal.PlainDate.from("1987-08-01").withCalendar("chinese").monthCodeis"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 闰六月:M06Lfrom 1987-07-26. Same for 1917 (M02L) and 1922 (M05L).ChineseCalendar(chnsecal.cpp): twelve events between 1901 and 2101 fall within minutes of midnight UTC+8 andCalendarAstronomerputs 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.DateTimeFormatwithcalendar: "chinese"shows the same dates, so Linux and Windows disagree with macOS today.Fix
icu/chinese-calendar-hko.patch, applied next toudata-decompress-hook.patchin every image that builds ICU (glibc, musl, Windows cross, Android, FreeBSD). It is thechnsecal.cpppart 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.DangiCalendarshares, which moves six Korean month starts away from the KASI tables (for example 설날 1954 from Feb 4 to Feb 3).jsclinked against ICU 78.3 plus this patch: every lunation 1900-2102 matches ICU4X'schina_data.rs/qing_data.rsexcept 2057-09-28/29 (left as computed, see Notes),korea_data.rsdifferences are the same three as without the patch, a per-day walk over 1900-2101 finds no field inconsistency or round-trip failure forchineseanddangi, and test262intl402/Temporalpasses 2029/2029 (the*/monthCode/chinese-calendar-dates.jsand*/daysInYear/basic-chinese.jsentries inexpectations-linux.yamlpass with it).Background
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 throughucal_*, 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
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 applyudata-decompress-hook.patcheither and is left alone.jscwith--useTemporal=1.