RFC-0049: Time scaling
Status: Landed. Specifies the programmatic real-time factor
(set_time_scale / time_scale() / step_dt()), the two world
pseudo-components that carry it, the rule that every baked dt must be
scaled through one helper, and the deliberately separate viewer step rate.
No frozen contract is broken: at the default scale 1.0 every lowered program
is bit-identical to the pre-RFC one.
Motivation
A simulator needs two things its users phrase as "make it faster":
- Faster in simulated time — run 60 simulated seconds per wall-clock
second without changing a single line of the model. This is a scaling of
the model's own clock; the integrator must genuinely take longer steps, or
the reported
tand the state disagree. - Faster on screen — the live viewer should not be pinned to one step per rendered frame. This is a rate, not a physical quantity: it changes how many steps a frame runs, never what a step means.
Conflating the two is the classic error. Scaling only the reported clock makes
the trajectory stand still while t runs away (and vice versa); scaling the
physics dt of a viewer by a slider re-derives every coefficient in the model
and hands a degenerate step to the solver.
RFC-0049 defines (1) as a runtime execution-context setting with one
normalisation point, and pins (2) as an explicitly different control that
lives in the presentation layer and never reaches ExecEnv.
Design
The scale is a runtime execution-context setting, not a model property
ExecEnv carries time_scale: f64, default 1.0. It belongs to the
execution context — the model never declares it, and it survives recompiles.
set_time_scale(requested) -> applied : f64
if requested.is_finite() && requested >= 0.0:
time_scale = min(requested, TIME_SCALE_MAX)
return time_scale
TIME_SCALE_MAX = 1.0e10(eir.rs) — exact inf64, far past any useful real-time factor, and a runaway model can never turn the effective step into a non-finite number.- A negative,
NaNor infinite request is rejected: the current scale is left untouched and returned, so the caller observes the refusal. This isExecEnv::set_time_scale, theSetTimeScaleEIR opcode and the host API — one definition, no drift. 0is valid: it pauses the clock while the step still runs. A step that asks for0is a model decision, not an error.
The effective step is
dt_eff = sim_dt * time_scale
and it applies from the next step. A set_time_scale issued inside a
step therefore never produces a half-scaled step: step n runs entirely at
scale s, step n+1 entirely at the new scale. scene.sim_time advances by
dt_eff per step, so the clock and the state move together.
time_scale() reports the scale governing the step in progress;
step_dt() reports dt_eff, the length the step in progress is actually
integrating.
Transport: two world pseudo-components, read through ReadView
The scale has to reach lowered EIR — including the threaded dispatcher and the native JIT — without turning a pure function into an opaque host call. It is carried as two world pseudo-components, canonical ids:
| id | name | read by |
|---|---|---|
pwe.time.scale (v1) |
current time scale | time_scale(), the scaled dt |
pwe.time.step_dt (v1) |
current effective step | step_dt() |
Neither belongs to an entity. SceneRuntime::set_time_context(time_scale,
step_dt) stamps both at step start; SceneRuntime::new defaults them to
1.0 / 0.0 (unscaled, no known step), and every production step path
stamps explicitly before interpreting. Reads go through ReadView, so the
instructions stay byte-identical between interpreter, JIT and threaded
backends — step_cross compares them, and a backend that ignored the scale
would be caught immediately.
Scaling a step means scaling every coefficient derived from dt
This is the substance of the RFC and the part that is easy to get half-right.
physics_eir::scaled_dt_reg(base, next_id, out) is the single helper: it
emits ReadView(pwe.time.scale) then Mul, returning base * time_scale.
Because x * 1.0 == x exactly in f64, a scale of 1.0 is bit-identical to
baking the constant — every conformance and benchmark run is unaffected.
Every place that bakes a dt into an immediate must route through it:
| system family | what is scaled |
|---|---|
update |
the dt local the body reads |
rk4 |
both substep shifts (half-step for stages 0–2, full step for the combine) and the dt/6.0 combine weight — not the substep count |
gillespie |
the reaction window |
wave |
the Courant number (v·dt/dx), scaled before it is squared, so the operand cfl² becomes cfl²·scale² |
nbody |
the Verlet kick, the Verlet drift, and the damping decay (1 - damp·dt — scale the decay, not the keep factor, or keep inflates past 1) |
pair / drift |
the pairwise kick, the drift, and the damping decay |
gravity / force / linear / integrate |
gravity_y·dt, a·dt, both parts of the linear delta, and the integrate displacement |
Two consequences are worth stating because both were real defects:
- Scaling only the clock while leaving
rk4's stage shifts baked produced a state identical at scale 1 and scale 2 — the trajectory frozen whiletadvanced. The fix isscaled_dt_regon the shifts and the combine weight; the substep count stays fixed, becausesubstepsis a resolution, not a length. - The damping
keepfactor must not be scaled:keep = 1 - damp·dt·scaleis correct,keep = (1 - damp·dt)·scaleis not.
The simulation clock's sim_dt
sim_dt — the multiplier for time_scale and the fallback step length — is
resolved in this order:
- the first
updatesystem'sdt, - else the first
gillespiesystem'sdt, - else the first system that declares a
dtat all (rk4,nbody,pair,drift,wave,gravity,integrate,force,linear, …), - else
1/60.
Clause 3 matters: without it an rk4-only program fell back to 1/60 while
its state integrated at the declared dt, so the clock and the model disagreed
by a factor of dt · 60.
Host / CLI surface
pwe run model.pweb --time-scale 5 # 5× simulated speed
pwe run model.pweb --time-scale 0.25 # quarter speed
--time-scale requires a finite, non-negative number, reports when it clamps
above TIME_SCALE_MAX, and is orthogonal to --max-steps-per-frame.
The viewer speed control is a different thing
pwe present exposes a slider (log10, 1e-8 … 1e10) and an exact-value box
near the bottom-right buttons. Both send a steps-per-frame rate to
/set-time-scale (and, in the playground, /api/set-time-scale), which the
stepping loop reads as "how many steps this frame".
- It never writes
ExecEnv::time_scale. The physicsdtis untouched, so no coefficient in the model is re-derived and no step becomes degenerate — which is precisely what keeps the RFC-0021EirInvalid (18)guard from firing on a slider drag. - Unrun steps are dropped, not queued:
speed_framecarries only the fraction, so a burst at1e10cannot leave a backlog that outlives the request. The burst is bounded by a per-frame wall-clock budget (STEP_BUDGET_MS/PLAYGROUND_STEP_BUDGET_MS), not by a numeric cap. /stateechoes the applied value astime_scaleand the simulated seconds actually advanced this frame aseffective_dt, both formatted withfmt_f64so the JSON stays parseable.- The request is validated once, in
apply_time_scale_query, shared by thepwe presentroute, the playground handler and the token-carrying viewer rewrite. - The browser keeps a
pendingScaleuntil an echo matches it (with a deadline), so a poll that left before the request landed cannot snap the control back to the previous value; a cancelled pointer drag clearsscaleDragging, or the readout would stop following the runtime forever.
Validation
- Conformance:
RFC-0049 time scale (applies from next step, clock advances 1+2)— after two steps with scales1.0 → 2.0the clock advanced1 + 2 = 3seconds. reference/src/lang/tests.rs:rfc_0049_time_scale_scales_the_clock_and_the_step_dtrfc_0049_time_scale_change_applies_from_the_next_steprfc_0049_invalid_time_scales_are_rejected_and_zero_pausesrfc_0049_body_dt_is_the_effective_scaled_steprfc_0049_host_api_shares_the_builtin_definitionrfc_0049_time_scale_moves_the_rk4_integrator— harmonic oscillator(cos t, -sin t); ten steps land ont = 1unscaled andt = 2at scale 2, and the clock follows the declareddt.rfc_0049_time_scale_moves_the_gravity_and_integrate_systems— free fall-5.5unscaled vs-22.0at scale 2, exactly thet → 2tsignature.rfc_0049_time_scale_moves_the_nbody_integratorrfc_0049_time_scale_moves_the_wave_courant_coefficient
reference/src/present.rs:set_time_scale_query_validates_and_clamps,live_state_json_stays_parseable_for_extreme_values,speed_frame_is_a_rate_not_a_backlog.
All of the above are stepped cross-backend, so interpreter ≡ JIT ≡ threaded dispatcher must agree on every scaled read.
Non-goals
- No change to what
dtmeans for a system. The declareddtstays a physical model constant; the scale is a runtime multiplier of it. - No wall-clock time dilation of the render loop, and no sleeping to hit a target rate. The viewer's rate is a step count, not a duration.
- No per-entity or per-system scale. The execution context has one.
- No weakening of the RFC-0021
EirInvalid (18)guard.dt_effis finite and strictly positive for any accepted request;time_scale = 0is an explicit pause, not a divisor.
Alternatives
- Scale only
scene.sim_time. Rejected — the clock runs while the state stands still, which is worse than no feature. - Rescale
dtin the host before lowering. Rejected — the baked constant would differ between backends and between runs of the same source, breaking deterministic replay andstep_crossbyte-comparison. - A single
time_scale()multiply inside each system body. Rejected — every system would need its own copy of the read, and a system that forgot would diverge silently. One helper, called at each bake site, keeps the mistake loud in review. - Let the viewer slider write
ExecEnv::time_scale. Rejected — it would re-derive every model coefficient per drag and hand the solver a near-zero or enormous step; the step-rate control exists specifically to avoid this.