Skip to content

Place events on the time elapsed since the call started (#110) - #119

Merged
wshlavacek merged 2 commits into
mainfrom
fix/elapsed-clock-from-origin
Oct 22, 2026
Merged

wshlavacek merged 2 commits into
mainfrom
fix/elapsed-clock-from-origin

Conversation

@wshlavacek

@wshlavacek wshlavacek commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #110.

Since #98 a run's seed state sits at ts.t_start on the model's clock, and
run_ssa placed every event at current_time + tau in doubles. Once the
total propensity times |t| nears 1e15, tau is below the spacing of the
doubles near t and is rounded away or up to a whole spacing: the run fires
too slowly or not at all, with no error. The tolerance for "this sample is
at the start", 1e-12 * |t_end|, is 10 time units from t_start = 1e13, so a
sample at t_start + 2 recorded the seed state. A(s0) -> A(s1) at
k = 0.5 from 1e5 copies, mean of 10 seeds at t0 + 2 against 1e5/e = 36788:

t0       main                    this
0        36820.8   z = +0.7      36820.8
1e9      36820.5   z = +0.7      36820.8
1e12     22824.8   z = -290      36820.8
1e13     100000    (seed state)  36820.8
1e15     100000    (seed state)  36820.8

The same decay at k + a*(time() - T0) from 1e13 does not finish on main in
20 minutes; a stack sample finds it inside next_clocked_event_time. It
takes a fraction of a second here.

run_ssa now keeps the clock as an origin, current_time where the call
starts, plus the time elapsed since. Events, sample positions and the
clock-driven hazard's panels go on the elapsed part; current_time, which
time(), a TFUN's time counter, the sample labels and save_state read, is
origin + elapsed. The origin is set again at every call (run, simulate,
step_to), so resolution depends on how long a call is, not where it sits,
and nothing new goes into the state file: a continuation in process and
one from a saved state start from the same current_time. A grid point
sits at start + idt on the elapsed clock, so from 1e16, where doubles are
2 apart and the labels t0 + 0.5
i round together, the states are still the
run from 0's. t_eps scales with the call's span. A call that runs to the
end leaves current_time at ts.t_end itself, not origin + (t_end - origin),
which for 0.7 and 2.9 is 2.9000000000000004. Only the clock value a rate
law reads is rounded, which BNG's network simulate has too.

Adjacent fixes, each found while checking this one:

  • A session of a model whose rates do not read the clock kept the time of
    its last event once nothing could fire. The absorbing branch recorded
    the remaining samples and stopped without moving the clock to t_end, so
    the next simulate() from t_end was refused for starting after the
    session's time, and a session from an empty pool stayed at its start.
    The clock now goes to t_end, as it does when a draw outlasts the call.
    A parameter_scan carry-over chain's point after one that decays out now
    starts at t_end, not at the last event.

  • An infinite t_end was accepted and went wrong. run({0, inf, 2})
    labelled its rows NaN, inf, inf with every one at the end state; with
    no grid, both rows held the seed state; a model that never stops firing
    never returned. With the fix above, step_to(inf) on a pool that decays
    out would leave current_time() at inf, which load_state cannot read
    back. Engine::run, which every run, simulate, step_to, ensemble
    replicate and scan point goes through, now refuses a t_end that is not
    finite, as initialize() already refuses such a t_start. simulate()'s
    start check, |t_start - session time| > tol, was false for a NaN
    t_start, which then ran with its first row labelled NaN; it is written
    so NaN fails it. A NaN anywhere in sample_times is refused too (second
    commit): it passed the ascending-order check and stalled the sample
    cursor, so with {0, NaN, 1, 2, 4} the rows at 1 and 2 held the state
    at 4.

  • simulate(TimeSpec) with sample_times checked only the last sample
    against the session's time, so a t_end before it ran nothing and
    labelled the session's state with the later sample times. That
    includes t_end left at its default 0, which is how
    session_sample_times_test's mid-protocol case called it: its rows at
    6.5, 8 and 10 were the state at 5, and the test checked only the
    labels. The refusal matches simulate(t_start, t_end, n). The test now
    sets t_end and checks the segment against a uniform second segment at
    the instants they share, bit for bit.

Blast radius, rm_driver --print-functions against main over all 262 XMLs
under tests/, seeds 1 and 2:

  • one-shot from t = 0: 262/262 byte-identical;
  • one-shot from --t-start 5 over the same span: 262/262 byte-identical,
    rates that read the clock included;
  • split at half the run (--save-state, then --load-state --t-start):
    251/262 byte-identical. The 11 that differ reach an absorbing state
    before the split: main saves the last event's time and refuses its own
    continuation, and this branch continues.
    ctest 67/67 (late_start_test, clock_origin_test and session_sample_times_test
    also under asan), harness/basicmodels.py 29/29.

late_start_test checks the following:

  • seed for seed, that a run over [t0, t0 + 2] from 1e9, 1e12, 1e13 and
    1e15 is the run over [0, 2], on a grid and on listed sample times, on
    the plain path and on the clocked one with a flat rate;
  • that a grid from 1e16 is the run from 0 too;
  • that a late session advanced by two simulate() calls, or by step_to
    and simulate(), is the session from 0;
  • that parameter_scan's fresh points and carry-over chain from a late
    start are the ones from 0;
  • that a session parked at 1e13, then given molecules, runs as one at 0;
  • that a late session continued in process and from its saved state
    agree;
  • that a ramp, a step and a pulse reading time() - T0 from 1e13 match
    their closed forms;
  • that clock() reads each label;
  • that a TFUN time counter reads the model's clock in a session's second
    call;
  • that a call ends at exactly t_end;
  • that a session, and a carry-over chain, continue past an absorbing
    state;
  • and the refusals.
    Against main it fails 24 checks, and the late ramp, step and pulse do not
    finish there, so the count is taken with those three skipped; the test has
    a 300 s timeout. clock_origin_test's flat run over
    [5, 7] now matches the one over [0, 2] exactly, where it allowed a
    difference of 1.

Mutations: 21 of 23 killed. The two survivors cannot change output:

  • grid points at i*dt rather than at the segment start's offset, which
    differ only when a session's t_start is within simulate()'s
    tolerance of its clock but not equal to it;
  • the t_end sample of a call with no grid, which records the final
    state either way.

Since #98 a run's seed state sits at ts.t_start on the model's clock, and
run_ssa placed every event at current_time + tau in doubles.  Once the
total propensity times |t| nears 1e15, tau is below the spacing of the
doubles near t and is rounded away or up to a whole spacing: the run fires
too slowly or not at all, with no error.  The tolerance for "this sample is
at the start", 1e-12 * |t_end|, is 10 time units from t_start = 1e13, so a
sample at t_start + 2 recorded the seed state.  A(s~0) -> A(s~1) at
k = 0.5 from 1e5 copies, mean of 10 seeds at t0 + 2 against 1e5/e = 36788:

    t0       main                    this
    0        36820.8   z = +0.7      36820.8
    1e9      36820.5   z = +0.7      36820.8
    1e12     22824.8   z = -290      36820.8
    1e13     100000    (seed state)  36820.8
    1e15     100000    (seed state)  36820.8

The same decay at k + a*(time() - T0) from 1e13 does not finish on main in
20 minutes; a stack sample finds it inside next_clocked_event_time.  It
takes a fraction of a second here.

run_ssa now keeps the clock as an origin, current_time where the call
starts, plus the time elapsed since.  Events, sample positions and the
clock-driven hazard's panels go on the elapsed part; current_time, which
time(), a TFUN's time counter, the sample labels and save_state read, is
origin + elapsed.  The origin is set again at every call (run, simulate,
step_to), so resolution depends on how long a call is, not where it sits,
and nothing new goes into the state file: a continuation in process and
one from a saved state start from the same current_time.  A grid point
sits at start + i*dt on the elapsed clock, so from 1e16, where doubles are
2 apart and the labels t0 + 0.5*i round together, the states are still the
run from 0's.  t_eps scales with the call's span.  A call that runs to the
end leaves current_time at ts.t_end itself, not origin + (t_end - origin),
which for 0.7 and 2.9 is 2.9000000000000004.  Only the clock value a rate
law reads is rounded, which BNG's network simulate has too.

Adjacent fixes, each found while checking this one:

- A session of a model whose rates do not read the clock kept the time of
  its last event once nothing could fire.  The absorbing branch recorded
  the remaining samples and stopped without moving the clock to t_end, so
  the next simulate() from t_end was refused for starting after the
  session's time, and a session from an empty pool stayed at its start.
  The clock now goes to t_end, as it does when a draw outlasts the call.
  A parameter_scan carry-over chain's point after one that decays out now
  starts at t_end, not at the last event.

- An infinite t_end was accepted and went wrong.  run({0, inf, 2})
  labelled its rows NaN, inf, inf with every one at the end state; with
  no grid, both rows held the seed state; a model that never stops firing
  never returned.  With the fix above, step_to(inf) on a pool that decays
  out would leave current_time() at inf, which load_state cannot read
  back.  Engine::run, which every run, simulate, step_to, ensemble
  replicate and scan point goes through, now refuses a t_end that is not
  finite, as initialize() already refuses such a t_start.  simulate()'s
  start check, |t_start - session time| > tol, was false for a NaN
  t_start, which then ran with its first row labelled NaN; it is written
  so NaN fails it.

- simulate(TimeSpec) with sample_times checked only the last sample
  against the session's time, so a t_end before it ran nothing and
  labelled the session's state with the later sample times.  That
  includes t_end left at its default 0, which is how
  session_sample_times_test's mid-protocol case called it: its rows at
  6.5, 8 and 10 were the state at 5, and the test checked only the
  labels.  The refusal matches simulate(t_start, t_end, n).  The test now
  sets t_end and checks the segment against a uniform second segment at
  the instants they share, bit for bit.

Blast radius, rm_driver --print-functions against main over all 262 XMLs
under tests/, seeds 1 and 2:
  - one-shot from t = 0: 262/262 byte-identical;
  - one-shot from --t-start 5 over the same span: 262/262 byte-identical,
    rates that read the clock included;
  - split at half the run (--save-state, then --load-state --t-start):
    251/262 byte-identical.  The 11 that differ reach an absorbing state
    before the split: main saves the last event's time and refuses its own
    continuation, and this branch continues.
ctest 67/67 (late_start_test, clock_origin_test and session_sample_times_test
also under asan), harness/basicmodels.py 29/29.

late_start_test checks the following:
  - seed for seed, that a run over [t0, t0 + 2] from 1e9, 1e12, 1e13 and
    1e15 is the run over [0, 2], on a grid and on listed sample times, on
    the plain path and on the clocked one with a flat rate;
  - that a grid from 1e16 is the run from 0 too;
  - that a late session advanced by two simulate() calls, or by step_to
    and simulate(), is the session from 0;
  - that parameter_scan's fresh points and carry-over chain from a late
    start are the ones from 0;
  - that a session parked at 1e13, then given molecules, runs as one at 0;
  - that a late session continued in process and from its saved state
    agree;
  - that a ramp, a step and a pulse reading time() - T0 from 1e13 match
    their closed forms;
  - that clock() reads each label;
  - that a TFUN time counter reads the model's clock in a session's second
    call;
  - that a call ends at exactly t_end;
  - that a session, and a carry-over chain, continue past an absorbing
    state;
  - and the refusals.
Against main an earlier version of it failed 10 checks, and the late ramp
does not finish; the test has a 300 s timeout.  clock_origin_test's flat run over
[5, 7] now matches the one over [0, 2] exactly, where it allowed a
difference of 1.

Mutations: 20 of 22 killed.  The two survivors cannot change output:
  - grid points at i*dt rather than at the segment start's offset, which
    differ only when a session's t_start is within simulate()'s
    tolerance of its clock but not equal to it;
  - the t_end sample of a call with no grid, which records the final
    state either way.
The ascending-order check compared neighbours with <, which is false
for NaN, so a NaN passed it.  The recorder's cursor then stopped on it,
since no clock reading is >= NaN, and every sample after it was recorded
at the final state.  run() with sample_times {0, NaN, 1, 2, 4} on
late_start_model at seed 1 gave A0 = 1341 at every row from the NaN on,
where 1 and 2 should read about 6065 and 3679.  Present on main; it is
the same class as the NaN segment start the previous commit refuses, and
the PR's description of that refusal would otherwise suggest the
non-finite holes were closed.

run_ssa now refuses a NaN anywhere in the list, before any event, so a
session is left where it was.  Finite lists, and every rm_driver run,
which never passes sample_times, take the same path as before.

late_start_test checks that run() and simulate(TimeSpec) both refuse
such a list and that the session does not move.  The mutant without the
guard fails it.  Against main, the test now fails 24 checks, with the
late ramp, step and pulse skipped because they never finish there.
ctest 67/67.
@wshlavacek
wshlavacek merged commit 2536c24 into main Oct 22, 2026
13 checks passed
@wshlavacek
wshlavacek deleted the fix/elapsed-clock-from-origin branch October 22, 2026 23:43
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.

A run from a large t_start loses its waiting times to rounding: keep the clock as origin + elapsed

1 participant