Skip to content

Fix time-distortion offset math and Clock.offset double-counting; restore + harden the test suite#2

Merged
boatmeme merged 17 commits into
mainfrom
fix-dilation
Jun 27, 2026
Merged

Fix time-distortion offset math and Clock.offset double-counting; restore + harden the test suite#2
boatmeme merged 17 commits into
mainfrom
fix-dilation

Conversation

@boatmeme

Copy link
Copy Markdown
Owner

Summary

This branch fixes two correctness bugs in the clock-distortion engine, repairs the
(previously unrunnable) test suite, and substantially hardens it. The README's own
documented example now actually reproduces.

Bugs fixed

1. distortTime used the wrong formula. Clock adds distortTime(dt) to an offset
that is itself added to real time, so the correct contribution is the offset delta
dt * (relative/reference − 1). Both distortions computed something else
(−dt*(rel/ref) and +dt*(ref/rel)). The dilation half of the README worked only by
coincidence at a 2:1 ratio; compression never re-synced. Both classes now use the single
correct formula, and the full README hour-by-hour table reproduces exactly (including the
10:00am re-sync).

2. Clock.offset double-counted. The getter folded the elapsed [lastCheck, now]
slice into _offset but left _lastCheck unadvanced — that was done separately on the
relativeTimeInMillis path. Reading offset on its own (or twice) re-accumulated the same
interval. It now advances _lastCheck itself, consuming each slice exactly once;
relativeTimeInMillis behavior is unchanged.

Test infrastructure

Fake timers fixed. Modern Node makes globalThis.performance non-configurable, which
Jest 28's bundled fake-timers can't hijack — the entire suite threw on setup. Added
fakeTimers: { doNotFake: ['performance'] }. The suite now runs on Node 18 and 24.

Tests that had been written to match the buggy output were corrected to assert the right
behavior (the old 'documentation example' never exercised compression; 'returns the relative time' asserted 1.5× where its own comment said "double-time"; the isolated
distortTime tests encoded the wrong semantics).

Hardening (new tests)

  • Invariants: polling-frequency independence, distortion order independence, round-trip
    net-zero (with symmetric lag profile).
  • Correctness locks: DST-aware TimeWindow durations (spring-forward 3h→2h real,
    fall-back 3h→4h real), half-open [start, end) window boundaries, ClockTime.add
    midnight wrap + sub-second precision.
  • Characterizations of known gaps: midnight-wrapping distortion windows go dead after
    the date rolls over (the README's overnight scenario); unvalidated degenerate durations
    produce Infinity/NaN and can poison relativeTimeInMillis permanently. Both are
    already noted in the README roadmap and pinned here as regression targets.
  • Acceptance scenarios: the Christmas-morning story and a pure-dilation "never recovers"
    case, as executable documentation.

Follow-ups surfaced (not in this PR)

Verification

npm run test:ci: 47 tests, 10 suites, all green, 100% coverage, lint clean, on both
Node 18 and Node 24.

boatmeme and others added 17 commits June 26, 2026 17:43
Constant distortions are linear, so the accumulated offset must not
depend on how finely the clock is polled. Poll the README example once
vs. every minute across the same span and assert identical results.
This property guards Clock's offset-accumulation/overlap-clamping math
against whole classes of regressions.
Distortion offsets are summed, so the order of the array passed to
Clock must not affect the result. Walk a forward- and reverse-ordered
clock through identical hourly checkpoints and assert they agree at
every step.
A dilation that loses 3h followed by a compression that reclaims exactly
3h must return the clock to perfect sync and leave no residual drift.
Also pins the symmetric lag profile (-1.5h at 4am and 8.5am, -3h peak at
7am) through the excursion.
The offset getter folded the elapsed [lastCheck, now] slice into _offset
but left _lastCheck unadvanced; advancing it was done separately by
markLastCheck() on the relativeTimeInMillis path. So reading offset on
its own (or twice) re-accumulated the same interval, inflating the
distortion.

Advance _lastCheck inside offset so each slice is consumed exactly once,
and drop the now-redundant markLastCheck(). relativeTimeInMillis is
unchanged in behavior. Adds tests asserting repeated reads are stable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A distortion window is described as a wall-clock time-of-day and always
resolved against the current calendar date, so a window that wraps past
midnight goes dead once the date rolls over: its start is recomputed as
tonight's (future) occurrence and the in-progress distortion drops out.

Pin both the working before-midnight case and the broken after-midnight
case so the gap is visible and a future date/timezone-aware version has
a regression target. This is the README's overnight scenario.
The library does not validate distortion parameters (README roadmap #2).
Pin current behavior: a zero reference duration makes distortTime return
Infinity, two zero durations return NaN, and a negative relative duration
silently produces an exaggerated dilation. End-to-end, a zero-reference
window poisons relativeTimeInMillis with NaN permanently -- documented
here as motivation for future validation.
ConstantTimeDilation and ConstantTimeCompression share one formula, so
their names are advisory intent rather than enforced behavior. Pin that
identical params yield identical results across the two classes, and that
a "dilation" with relative > reference speeds the clock up while a
"compression" with relative < reference slows it down.
durationInMillis is real elapsed time between two wall-clock times, so
across a DST transition it diverges from the nominal wall-clock span.
Assert America/New_York loses an hour on the spring-forward day (3h span
=> 2 real hours), gains one on fall-back (3h span => 4 real hours), and
matches the span on an ordinary day. Guards the Temporal-backed behavior.
compareWithinWindow treats [start, end) as half-open: the start instant
is WITHIN, the end instant is LATER. Clock depends on this so adjacent
non-overlapping windows hand off without gap or overlap. Assert both
boundaries to the millisecond.
Assert add advances the wall-clock time, wraps past midnight (a time-of-
day carries no date, so 23:00 + 2h is 01:00), and preserves millisecond
precision. Also assert forTodayInMillis resolves seconds and milliseconds.
The matrix tested 12/14/16/18, all now end-of-life, while the bug that
broke the suite was a modern-Node issue. Switch to 18/20/22/24 (18 kept
as a compatibility floor). Verified all four pass locally.
Pin actions/checkout (v7.0.0) and actions/setup-node (v6.4.0) to commit
SHAs in the publish workflow, matching the CI workflow. This is the
workflow that holds the npm token, so tag-hijack hardening matters most
here. Also correct the misleading "GitHub Packages" comment to "npm
registry", which is what it actually targets.
@boatmeme boatmeme merged commit a402512 into main Jun 27, 2026
4 checks passed
@boatmeme boatmeme deleted the fix-dilation branch June 27, 2026 01:05
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.

1 participant