Skip to content

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

Open
Montana wants to merge 1 commit into
lanl:mainfrom
Montana:fix/1277-fixed-and-moving-fire-one-instant
Open

Montana wants to merge 1 commit 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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants