Skip to content

events: refuse a fixed fire and a moving one on one instant across two batches (#1277) - #1353

Merged
wshlavacek merged 3 commits into
lanl:mainfrom
Montana:fix/1277-fixed-and-moving-fire-one-instant
Oct 31, 2026
Merged

wshlavacek merged 3 commits into
lanl:mainfrom
Montana:fix/1277-fixed-and-moving-fire-one-instant

Conversation

@Montana

@Montana Montana commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1277.

What was wrong

An event on the clock and an event on the state that cross on one instant fire as two batches, a root's slack apart, when the first is at a fixed time and a requested column moves the second. #49's refusal, of two events on one instant whose times move differently (fixed_event >= 0 && tau_event >= 0 in apply_event_sensitivity_jump), is asked of one batch. It never saw the two together.

#1277's model:

  • S' = 1, q' = k;
  • E0: at (time >= 2.5): S = S + 1;
  • E1: at (q >= 2.5): S = S/2.

On main 9a26f5b:

k S(3), plain run order
1 − 1e-6 3.7499987 E0 at 2.5, then E1 at 2.5/k
1 3.7500000 E0 first
1 + 1e-6 4.2500012 E1 first

sensitivity_params=["k"] at k = 1 returned dS/dk(3) = 1.25 with no error. That is one side's slope at a jump, not a derivative.

The fix

src/cvode_simulator.cpp, apply_event_sensitivity_jump. Before a fire is recorded in event_fires, it is asked against every earlier fire kept there. If exactly one of the two is moved by a requested column (EventFire::moves against this fire's fire_moves), and the two are within 2·kEventRootSlack·kSwitchInstantUlps·ε·max(|t|, 1) of each other, the run is refused. That is the same reach clock_crossings_at gives an event root beside a switch, 2.8e-13 at t = 2.5. The message names both events and which one moves:

Forward sensitivity: events 'E0' and 'E1' fire on one instant, t=2.5, in two batches, and
their times move differently with the requested parameters: 'E0' does not move and 'E1'
does. Their order flips across the parameter, so the result jumps there and has no
derivative (issues #49, #1277). Separate the trigger times, or drop the parameters that
move them from sensitivity_params.

This is #1277's proposal: event_fires already holds each earlier fire and whether it moves.

The check matches #49's refusal within one batch: it does not test whether the two events commute.

Tests (test_event_batch_order_sensitivity.py)

  • Refused, in either order: test_a_fixed_fire_and_a_moving_one_in_two_batches_are_refused, with the trigger at 2.5 − 1e-13 (E1 first), at 2.5, and at 2.5 + 1e-13 (E0 first). Probing showed 2.5 − 1e-14 lands in one batch, where Forward sensitivity w.r.t. a switch time encoded as an SBML event (event-time sensitivity dt*/dp != 0) #49's own refusal already applies, so the "E1 first" case sits at −1e-13 to reach the two-batch path.
  • Runs, a millionth apart: test_the_pair_a_millionth_apart_runs, with the trigger at 2.5 ± 1e-6. The order holds across k, and the run matches the closed form dS/dk = trigger/2.
  • Runs, no column moves either fire: test_the_pair_on_one_instant_runs_where_no_column_moves_either. With sensitivity_params=["a"] on the coincident pair, dS(3)/da = 1.75, the same in either order.

Mutations, each caught:

mutation caught by
refuse only "earlier fixed, this moving" the E1 first case
refuse only "earlier moving, this fixed" on and E0 first
the slack test dropped both "a millionth apart" cases, which are then refused
the slack ×0.01 E1 first and E0 first (1e-13 apart)

Measured

  • fd_sens: the nightly sweep (106 jobs on 53 .net models, sensitivities against central differences), run on main's core and on this one. Both give 106 PASS, with identical verdicts and no new refusal.
  • Full suite on this core: 16,404 passed, 0 failed.

…o batches (lanl#1277)

An event on the clock at a fixed time and one on a state a requested
column moves cross on one instant and fire as two batches, a root's slack
apart. lanl#49's refusal of two events on one instant whose times move
differently was asked of one batch, so it saw neither with the other:
S(3) went from 3.75 to 4.25 across k = 1, and the run returned dS/dk =
1.25, one side's slope.

Before a fire is recorded, it is now asked against each earlier fire in
event_fires: one moved and the other not, within 2·kEventRootSlack·
kSwitchInstantUlps·ε·max(|t|,1), and the run is refused, naming both.
@elyfmiller

Copy link
Copy Markdown
Collaborator

Thanks. I checked this at 3237402 against our #1326 (#947, head 0e791e3), which also changes how events on one instant are drained. The two don't conflict. There's one thing in the new message, below, that is independent of #1326. Measured on macOS arm64, with three builds: this PR alone, #1326 alone, and the two merged with git merge --no-commit (the merge is clean).

No conflict with #1326

#1326 holds a strict trigger, time > tau, behind its non-strict twin on the same residual, time >= tau, to a second sub-instant. It releases the held trigger into the same drain, so apply_event_sensitivity_jump still sees one batch and records one fire. #1326 doesn't touch event_fires, fire_moves or #49's checks, and a twin pair shares one residual, so its moves agree in any case.

On the merged build:

1. The refusal names the batch's first event, not the one that moves

This is #1277's model with a strict event beside E0:

species S, q; S = 0; q = 0; k = 1
J0: -> S; 1
J1: -> q; k
E0: at (time >= 2.5): S = S + 1
E0s: at (time > 2.5), priority = 3: S = S + 10
E1: at (q >= 2.5): S = S/2

With sensitivity_params=["k"], it's refused, which is right. At an exact clock stop time > 2.5 reads false, so E0s fires at its own root, in one batch with E1. The message says:

events 'E0' and 'E0s' fire on one instant, t=2.5, in two batches, and their times move differently with the requested parameters: 'E0' does not move and 'E0s' does.

E0s doesn't move with k; E1 does. The new block names fired.front() as the moving event. #49's same-batch check, further down, would refuse this batch first and name the pair correctly. Without this PR, the same run gives:

events 'E0s' and 'E1' fire at the same instant t=2.500000 but their crossing times move differently with the requested parameters: the first does not move and the second does.

A fix: ask #49's condition before the new block: fixed_event >= 0 && tau_event >= 0 with a nonzero tau. All three are set by then, after pass 2. A batch that gets past it moves as one, either every event or none, so fired.front() names the right event. So does the earlier fire's first event, because that batch passed the same check when it fired. Moving the new block below #49's check instead would need it to skip the fire it has just pushed onto event_fires.

@elyfmiller

Copy link
Copy Markdown
Collaborator

A heads-up from #1356, merged as 3df252c. #1356 reads a sensitivity run's row ahead from up to 1e-12 short of a sample, and fires what crosses there. With it on main, one case here fails: test_event_batch_order_sensitivity.py::test_a_fixed_fire_and_a_moving_one_in_two_batches_are_refused[E1 first].

The run is still refused, but by #49's same-instant message ("events 'E0' and 'E1' fire at the same instant t=2.500000 but their crossing times move differently … (issue #49)") rather than this PR's. E1's root lies within the instant of E0's clock stop, so the read-ahead fires the two at the row as one batch, and the cross-batch check never runs for that case.

Two ways to settle it:

We built main + #1356 + this PR (it merges clean in text): on this PR's test file and #1356's, 1 failed, 72 passed, and every other case passes. One more thing from that build: with this PR in, a clock trigger time > 2.5 at a row reached short, beside an event whose crossing moves with k, is refused by this PR's cross-batch check. #1356's tests leave that shape out for that reason. The details are in our #1356 comment: #1356 (comment).

…anl#1277 review)

The cross-batch refusal skipped a pair whenever both fires moved, so it
caught a fixed fire beside a moving one and nothing else. lanl#1277's model
with E0 at `time >= T` and sensitivity_params ["T", "k"] moves E0 by
dt/dT = 1 and E1 by dt/dk = -2.5: the order still flips across k = 1,
and the run returned dS/dk = 1.25 with no error where ["k"] alone was
refused. Within one batch, adopt_tau refuses exactly that.

Each kept fire now records its shift per column and the event that
moves it. Two fires within the slack are refused unless both are fixed
or every column's shifts agree to same_shift's tolerance, hoisted for
this: E1 at `q >= T` with q' = 1 beside E0 at T moves by a difference
that is 1 to rounding, and still runs (dS/dT = -0.5). The message names
the event that moves rather than the first that fired, and says "both
move, and not together" for that case. The slack is clock_crossings_at's
reach, now one helper.

The tests drop the row at 2.5: since lanl#1276 a row there gathers a fire
just short of it into its own batch, so "E1 first" at -1e-13 was
answered by lanl#49's refusal and the test failed on current main.
@wshlavacek
wshlavacek merged commit 15ba102 into lanl:main Oct 31, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants