From 5ff450c9484045154e3c1ed3f0ea398ade6f624a Mon Sep 17 00:00:00 2001 From: Bobby Grayson Date: Fri, 9 Oct 2026 08:18:00 -0400 Subject: [PATCH] std/temporal: split Y - 1 into century and year in Date.dayOfWeek `Date.dayOfWeek` is wrong on the interpreter and on Rust for every year ending in 00 that is not a multiple of 400 (1700, 1800, 1900, 2100, ...), all year long. js, py, java, cpp and csharp connect `dayOfWeek` to their own date code, so they were right; the interpreter, Rust and Lua run std's Temper. The program: let { Date } = import("std/temporal"); for (let y of [1600, 1700, 1800, 1900, 2000, 2100, 2200, 2300, 2400]) { console.log("${y}-01-01 ${new Date(y, 1, 1).dayOfWeek} ${y}-03-01 ${new Date(y, 3, 1).dayOfWeek} ${y}-12-31 ${new Date(y, 12, 31).dayOfWeek}"); } at origin/main, `temper run -b rust` against `-b js` (py and java print the same as js, and so does Python's `datetime.isoweekday`): rust js 1600-01-01 6 1600-03-01 3 1600-12-31 7 1600-01-01 6 1600-03-01 3 1600-12-31 7 1700-01-01 4 1700-03-01 7 1700-12-31 4 1700-01-01 5 1700-03-01 1 1700-12-31 5 1800-01-01 2 1800-03-01 5 1800-12-31 2 1800-01-01 3 1800-03-01 6 1800-12-31 3 1900-01-01 7 1900-03-01 3 1900-12-31 7 1900-01-01 1 1900-03-01 4 1900-12-31 1 2000-01-01 6 2000-03-01 3 2000-12-31 7 2000-01-01 6 2000-03-01 3 2000-12-31 7 2100-01-01 4 2100-03-01 7 2100-12-31 4 2100-01-01 5 2100-03-01 1 2100-12-31 5 and the interpreter, through `temper repl`: 1900-01-01 7 1900-03-01 3 2100-01-01 4 1700-01-01 4 2000-01-01 6 1900-01-01 was a Monday (1), not a Sunday (7). Gauss's formula gives January first of year Y as (1 + 5((Y-1) mod 4) + 4((Y-1) mod 100) + 6((Y-1) mod 400)) mod 7. std split Y itself into century and year-of-century and made up for the missing - 1 with offsets. That works until the year of the century is 00, because then Y - 1 is in the previous century. 2000 came out right only because 2000 mod 400 is 0, and the existing tests (1596, 2010-2012, 2023) never hit a bad year. It now takes the three remainders of Y - 1 directly, each made non-negative by adding its divisor, since `%` keeps the dividend's sign, so years before 1 still work. The month tables are unchanged. The divisors are literals on purpose: a helper taking the divisor as a parameter makes `%` able to bubble, and be-rust then refuses it ("Cannot translate Invalid"). After, the interpreter, rust, js, py and java all print the js column above. On rust, every year from -1199 to 2999, for January 1, the last day of February, March 1 and December 31, matches Python's `datetime` (years before 1 compared through the 400-year cycle): 4199 lines, no difference. types/date now logs January and March first of 1700, 1800, 1900, 2000, 2100 and 2400. With the temporal change reverted it fails on the interpreter and on rust with the left column, and passes on js. With it, typesDate passes on the interpreter (InterpreterFunctionalTests), js, py, rust, java 17 and cpp. :frontend:jvmTest (703 tests), ktlintCheck and detekt pass. What does not work: Lua is not installed here, so Lua, which also runs this code, was not run. Separately, be-cpp's own `Date::getDayOfWeek` returns 0 for every Sunday instead of 7 (2023-01-01 is 0 on cpp, 7 on js); the new test has no Sunday in it, and this change does not touch cpp. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Bobby Grayson --- .../resources/std/temporal/temporal.temper.md | 19 ++++++++++++------- .../resources/types/date/date.temper.md | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/frontend/src/commonMain/resources/std/temporal/temporal.temper.md b/frontend/src/commonMain/resources/std/temporal/temporal.temper.md index 6755d9c32..995461d4a 100644 --- a/frontend/src/commonMain/resources/std/temporal/temporal.temper.md +++ b/frontend/src/commonMain/resources/std/temporal/temporal.temper.md @@ -219,14 +219,19 @@ Here's just enough of a Date type to get us started. */ @connected public get dayOfWeek(): Int { - // Gauss's method. + // Gauss's method: January first of year Y is weekday + // (1 + 5((Y-1) mod 4) + 4((Y-1) mod 100) + 6((Y-1) mod 400)) mod 7, + // with Sunday 0. It is Y - 1 that splits into century and year: + // splitting Y instead is wrong for every year ending in 00 that + // is not a multiple of 400, such as 1900 and 2100. + // `%` keeps the dividend's sign, so each remainder adds its + // divisor and takes it again, to stay in range for years before 1. let y = year; - let c = if (y >= 0) { y / 100 } else { -(-y / 100) }; - let yy = y - (c * 100); - // See note below about avoiding negative modulus to see why - // some of the offsets differ from Wikipedia's rendering of - // Gauss's formula. - let janFirst = (8 + 5*((yy + 3) % 4) + 3*(yy - 1) + 5*(c % 4)) % 7; + let before = y - 1; + let mod4 = ((before % 4) + 4) % 4; + let mod100 = ((before % 100) + 100) % 100; + let mod400 = ((before % 400) + 400) % 400; + let janFirst = (1 + 5 * mod4 + 4 * mod100 + 6 * mod400) % 7; let table = if (isLeapYear(y)) { dayOfWeekLookupTableLeapy } else { diff --git a/functional-test-suite/src/commonMain/resources/types/date/date.temper.md b/functional-test-suite/src/commonMain/resources/types/date/date.temper.md index 3bf436e9a..0cde50130 100644 --- a/functional-test-suite/src/commonMain/resources/types/date/date.temper.md +++ b/functional-test-suite/src/commonMain/resources/types/date/date.temper.md @@ -78,6 +78,25 @@ ISO 8601 yonks: 0012-11-10 yonksAgo.year = 12 ``` +The weekday of a year ending in 00 needs the right century. A +January first or March first of 1900 or 2100 lands on a different day +than in 2000, since 2000 is a leap year and the others are not. + + for (let y of [1700, 1800, 1900, 2000, 2100, 2400]) { + console.log( + "${y}: Jan 1 weekday ${new Date(y, 1, 1).dayOfWeek}, Mar 1 weekday ${new Date(y, 3, 1).dayOfWeek}" + ); + } + +```log +1700: Jan 1 weekday 5, Mar 1 weekday 1 +1800: Jan 1 weekday 3, Mar 1 weekday 6 +1900: Jan 1 weekday 1, Mar 1 weekday 4 +2000: Jan 1 weekday 6, Mar 1 weekday 3 +2100: Jan 1 weekday 5, Mar 1 weekday 1 +2400: Jan 1 weekday 6, Mar 1 weekday 3 +``` + TODO: test negative year support once we've got a story for Python whose datetime.MIN_YEAR == 1.