From 92ca24254344a027c5d507bd0ef15d8b6760fcf6 Mon Sep 17 00:00:00 2001 From: Eric Boothe Date: Tue, 9 Jun 2026 18:59:56 -0600 Subject: [PATCH] =?UTF-8?q?fix(transpiler):=20YEAR/MONTH/DAY=20use=20UTC?= =?UTF-8?q?=20serial=20math=20=E2=80=94=20local-time=20getters=20read=20da?= =?UTF-8?q?tes=20a=20day=20early=20west=20of=20UTC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emitted lowerings called new Date((s-25569)*86400000).getMonth()/ getDate()/getFullYear() — LOCAL-time getters — so any engine runtime west of UTC read every Excel serial one day early (DAY(Jun-30-2023)=29). The DATE(YEAR(x),MONTH(x),DAY(x)) reconstruction idiom (live on A-1 Valuation!G7) rebuilt dates one serial low, shifting date-keyed COUNTIFS/SUMIFS windows and flipping comparison flags across whole sheets; the engine's results depended on the machine timezone. YEAR/MONTH/DAY now route through _serialToYMD (pure-integer UTC math, 1900-epoch-quirk aware); _serialToYMD added to the sheet-module helper imports. New TZ-pinned child-process cases in test-date-axis-sumifs.mjs run the engine under America/Denver and Pacific/Auckland with a hazard probe as negative control — RED pre-fix with exactly the A-1-observed values (DAY=29, rebuilt serial 45106), 26/26 GREEN post-fix. Full npm test + cargo test green. Found by the all-17-sheet warm-GT sweep on the post-#62 A-1 rebuild: the residual numeric divergences (Technology 284k, PP&E 84k, Lease Am 9k, Debt 7.4k, WC 5.9k, Valuation 7) all carry the one-day-shift signature. Co-Authored-By: Claude Fable 5 --- pipelines/rust/src/chunked_emitter.rs | 2 +- pipelines/rust/src/transpiler.rs | 11 +++- .../rust/tests/test-date-axis-sumifs.mjs | 60 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/pipelines/rust/src/chunked_emitter.rs b/pipelines/rust/src/chunked_emitter.rs index 767d2e5..ef47aae 100644 --- a/pipelines/rust/src/chunked_emitter.rs +++ b/pipelines/rust/src/chunked_emitter.rs @@ -291,7 +291,7 @@ fn write_sheet_module(partition: &SheetPartition<'_>, w: &mut W) -> st writeln!(w)?; // Runtime helpers for Excel functions — import from shared module - writeln!(w, "{}", "import { _div, _aggNum, _index, _match, _vlookup, _hlookup, _large, _small, _rank, _fn, _sumif, _sumifs, _countif, _countifs, _offset, _matchesCriteria, _colNum, _numToCol, computeNPV, computeIRR, computeXIRR, computePMT, computePV, computeFV, computeRATE, computeNPER, computeXNPV, _minifs, _maxifs, _averageif, _averageifs, _filter, _excelSerialFromYMD, _edate, _eomonth } from './_helpers.mjs';")?; + writeln!(w, "{}", "import { _div, _aggNum, _index, _match, _vlookup, _hlookup, _large, _small, _rank, _fn, _sumif, _sumifs, _countif, _countifs, _offset, _matchesCriteria, _colNum, _numToCol, computeNPV, computeIRR, computeXIRR, computePMT, computePV, computeFV, computeRATE, computeNPER, computeXNPV, _minifs, _maxifs, _averageif, _averageifs, _filter, _excelSerialFromYMD, _serialToYMD, _edate, _eomonth } from './_helpers.mjs';")?; writeln!(w)?; // compute(ctx) function diff --git a/pipelines/rust/src/transpiler.rs b/pipelines/rust/src/transpiler.rs index dc5c3e9..47c128a 100644 --- a/pipelines/rust/src/transpiler.rs +++ b/pipelines/rust/src/transpiler.rs @@ -566,9 +566,14 @@ fn transpile_function(name: &str, args: &[Expr], config: &TranspileConfig) -> St // ---------------------------------------------------------------- "TODAY" => "/* TODAY */ 0".to_string(), "NOW" => "/* NOW */ 0".to_string(), - "YEAR" => format!("/* YEAR */ new Date(({} - 25569) * 86400000).getFullYear()", arg(0)), - "MONTH" => format!("/* MONTH */ (new Date(({} - 25569) * 86400000).getMonth() + 1)", arg(0)), - "DAY" => format!("/* DAY */ new Date(({} - 25569) * 86400000).getDate()", arg(0)), + // YEAR/MONTH/DAY must use the UTC/epoch-quirk-aware serial helper. The old + // `new Date((s - 25569) * 86400000).getMonth()` used LOCAL-time getters, so + // any runtime west of UTC read every serial one day early (DAY(Jun-30)=29), + // shifting date-keyed COUNTIFS/SUMIFS windows and DATE(y,MONTH(x),DAY(x)) + // reconstructions by one day. + "YEAR" => format!("/* YEAR */ _serialToYMD({}).y", arg(0)), + "MONTH" => format!("/* MONTH */ _serialToYMD({}).m", arg(0)), + "DAY" => format!("/* DAY */ _serialToYMD({}).d", arg(0)), // DATE/EDATE/EOMONTH return INTEGER Excel day-serials via calendar-exact // helpers. The old `*365.25`/`*30.44` float approximation drifted up to // ~2.88 days/year, breaking exact-equality SUMIFS/MINIFS date-key lookups diff --git a/pipelines/rust/tests/test-date-axis-sumifs.mjs b/pipelines/rust/tests/test-date-axis-sumifs.mjs index 3b2793a..0f08f1f 100644 --- a/pipelines/rust/tests/test-date-axis-sumifs.mjs +++ b/pipelines/rust/tests/test-date-axis-sumifs.mjs @@ -229,6 +229,66 @@ console.log('Testing: EDATE clamp, EOMONTH last-day/leap-year, DATE integer seri cleanup(); } +// --------------------------------------------------------------------------- +// YEAR/MONTH/DAY timezone independence. The old lowering used LOCAL-time +// getters (`new Date((s - 25569) * 86400000).getDate()`), so any runtime west +// of UTC read every serial one day early: DAY(Jun-30-2023) = 29, and the +// DATE(YEAR(x),MONTH(x),DAY(x)) reconstruction idiom (live on A-1 +// Valuation!G7) rebuilt the date one serial low — shifting every date-keyed +// COUNTIFS/SUMIFS window downstream by a day. The engine must give the same +// (Excel-exact) answer in every timezone, so we run it in CHILD processes with +// TZ pinned west (America/Denver) and east (Pacific/Auckland) of UTC. +// --------------------------------------------------------------------------- +console.log('Testing: YEAR/MONTH/DAY are timezone-independent (UTC serial math)'); +{ + const JUN30 = ser(2023, 6, 30); // 45107 + const TZS = { '!ref': 'A1:B5' }; + TZS['A1'] = { t: 'n', v: JUN30 }; + TZS['B1'] = { t: 'n', v: 0, f: 'DAY(A1)' }; + TZS['B2'] = { t: 'n', v: 0, f: 'MONTH(A1)' }; + TZS['B3'] = { t: 'n', v: 0, f: 'YEAR(A1)' }; + // The Valuation!G7 idiom: rebuild a date from its own parts. + TZS['B4'] = { t: 'n', v: 0, f: 'DATE(YEAR(A1),MONTH(A1),DAY(A1))' }; + TZS['A5'] = { t: 'n', v: ser(2024, 3, 1) }; + TZS['B5'] = { t: 'n', v: 0, f: 'DAY(A5)' }; // month boundary, leap year + + // Build once; run the engine in TZ-pinned children. + const wb = { SheetNames: ['TZS'], Sheets: { TZS } }; + const tmp = mkdtempSync(join(tmpdir(), 'date47tz-')); + writeFileSync(join(tmp, 'm.xlsx'), XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })); + execFileSync(PARSER, [join(tmp, 'm.xlsx'), join(tmp, 'out'), '--chunked'], { encoding: 'utf-8', stdio: 'pipe' }); + + const childScript = ` + import e from ${JSON.stringify(pathToFileURL(join(tmp, 'out', 'chunked', 'engine.js')).href)}; + const r = e.run(); + // Hazard probe: what the OLD local-time lowering would compute for DAY in + // THIS child's TZ (proves the negative control actually discriminates). + const oldDay = new Date((${JUN30} - 25569) * 86400000).getDate(); + process.stdout.write(JSON.stringify({ oldDay, + d: r.values['TZS!B1'], m: r.values['TZS!B2'], y: r.values['TZS!B3'], + rebuilt: r.values['TZS!B4'], leapD: r.values['TZS!B5'] })); + `; + const runInTz = (tz) => JSON.parse(execFileSync( + process.execPath, ['--input-type=module', '-e', childScript], + { encoding: 'utf-8', env: { ...process.env, TZ: tz }, stdio: ['ignore', 'pipe', 'pipe'] } + )); + + const west = runInTz('America/Denver'); + // Negative control: in a west-of-UTC runtime the OLD lowering reads Jun 30 as + // 29 — if this ever stops holding, the test is no longer exercising the hazard. + assert(west.oldDay === 29, `negative control: old local-time DAY() reads Jun-30 as 29 in America/Denver (got ${west.oldDay})`); + assert(west.d === 30, `DAY(Jun-30-2023) = 30 in a west-of-UTC runtime (got ${west.d})`); + assert(west.m === 6, `MONTH(Jun-30-2023) = 6 in a west-of-UTC runtime (got ${west.m})`); + assert(west.y === 2023, `YEAR(Jun-30-2023) = 2023 in a west-of-UTC runtime (got ${west.y})`); + assert(west.rebuilt === JUN30, `DATE(YEAR,MONTH,DAY) round-trips the serial exactly (got ${west.rebuilt}, want ${JUN30})`); + assert(west.leapD === 1, `DAY(Mar-1-2024) = 1 in a west-of-UTC runtime (got ${west.leapD})`); + + const east = runInTz('Pacific/Auckland'); + assert(east.d === 30 && east.rebuilt === JUN30, `east-of-UTC runtime agrees (DAY=${east.d}, rebuilt=${east.rebuilt})`); + + rmSync(tmp, { recursive: true, force: true }); +} + console.log(''); console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`); process.exit(failed > 0 ? 1 : 0);