Skip to content

events: run a strict trigger after its non-strict twin on a crossing (#947) - #1326

Open
elyfmiller wants to merge 2 commits into
lanl:mainfrom
elyfmiller:fix/947-strict-after-nonstrict
Open

elyfmiller wants to merge 2 commits into
lanl:mainfrom
elyfmiller:fix/947-strict-after-nonstrict

Conversation

@elyfmiller

@elyfmiller elyfmiller commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #947.

Problem

Two events on one threshold, one strict and one not:

E1: at (lhs > tau),  priority = 3: X = 0.3*X
E2: at (lhs >= tau), priority = 2: X = 0.5*X

In exact arithmetic lhs >= tau is true on the crossing and lhs > tau only past it. So E2 executes first and E1 after it, reading the X that E2 left: X = 0.15.

Where both triggers read true at one read, they were seeded into one batch. E1 executed first by priority, and E2 then overwrote it with the value it froze at the trigger: X = 0.50.

Which of the two a run gave depended on where the read landed. The stop for a clock trigger and CVODE's located root land on the crossing, or a few to about a hundred doubles past it, and past it both triggers read true. #933 fixed the issue's own spelling, the bare time ⋛ tau. #1218 left event-trigger stops where the resolvers put them. The neighbours in the 2026-10-07 comment are still decided by rounding.

On main (aedadd8), 307 values of tau over 1e-3 to 1e6, rtol=1e-10:

lhs sequence (0.15) batch (0.50)
time 307 0
0.1*time 281 26
time/3 212 95
time - 1 204 103
3*time 270 37

The other cases:

  • State triggers do the same. With A' = k, A > tau beside A >= tau batches on all 300 of 300 random (k, tau) pairs, for k over 1e-6 to 1e6.
  • SSA has the same drain and the same defect. With time/3, 16 of 36 values of tau batch.

The rule

The rule was agreed for this issue before any code was written:

When the strict comparison and the non-strict one on the same residual both turn true at one read on a crossing, the strict one is held back to a second sub-instant of the same time. It is triggered once everything else of the instant has executed and settled, at the state that left: its trigger is re-read, its fromTrigger values are frozen, and its delay is read. Then it is drained like any batch.

Which comparisons pair. The comparison is the residual that event_trigger_residual_expr already parses out of a single-comparison trigger, oriented to rise where the trigger turns true, with whitespace and outer parentheses dropped.

  • tau <= time/3 pairs with time/3 > tau.
  • time/3 - tau >= 0 does not: it is a different pair of sides.
  • ==, != and joined conditions never pair.

On a crossing. The read must be on a crossing, not past a jump. One step back along the run, the non-strict twin must read false, and the residual must move over that step as it moved over the step before. Two triggers that turn true together through a jump have no instant between them, so they stay one batch:

  • a reaction firing past the threshold in SSA;
  • a piecewise of the time that switches;
  • an event assignment, which the drain's own re-check already handles.

The step back:

  • ODE: it reads CVODE's interpolant over its last step, so the trajectory that arrived, and falls back to the flow beyond it. It starts at 2·max(100ε(|t|+|h|), 64ε·max(|t|,1)), past where a root or a stop lands, and doubles, up to 2^20 times, where the residual moves by less than its own rounding.
  • SSA: it moves the time alone. The state holds still between firings.

Unchanged:

Where

  • NetworkModel::event_trigger_comparison(ei, &strict) and strict_beside_non_strict(rising) are in src/model.cpp. They are cached beside the residual, and a clone rebuilds them.
  • process_firing_batch in src/cvode_simulator.cpp and src/ssa_simulator.cpp holds the strict seeds out of the first sub-instant.
    • A held trigger that changes during the first sub-instant is no longer held. If it falls, it never fires, which is the sequence. If it falls and rises again, it is a cascade like any other.
    • When the first drain finds nothing left, it queues each held trigger that is still true, with its values read then, and drains again.
    • It shares fires, CASCADE_LIMIT and the trigger baseline with the first drain.

Sensitivity runs

A held release is composed by the sensitivity jump like any member of the batch, because the jump replays executed_fires in order from x⁻. That holds where the held event reads nothing the first sub-instant wrote and writes nothing it wrote. Then the sequence leaves what the batch left, and the run keeps its sensitivities.

Otherwise the run refuses by name: Forward sensitivity: event 'E1', a strict comparison, and event 'E2', the non-strict one on the same residual, turn true together on its crossing at t=… (issue #947), and 'E1' reads or writes a species they wrote: a second state jump at the same instant, which bngsim has no sensitivity jump to compose with the first (issue #144). The reads checked are the assignment values and the rescale sizes.

Before this change, such a run returned the batch's trajectory, which the plain run no longer gives. This is the consequence accepted with the rule.

RoadRunner

libRoadRunner 2.9.2 batches every such pair. So a corpus model with one would read as a DIFF in rr_ode against it. That divergence comes from the rule, and was accepted with it.

Results

After this change, every spelling gives 0.15 on the same 307 values of tau:

lhs sequence batch
time, 0.1*time, time/3, time - 1, 3*time 307 each 0

Also 0.15 for every one of 107 values of tau:

  • time > tau/7 and time > tau + 0.1, each beside its >= twin;
  • tau < time/3 beside tau <= time/3, and time/3 > tau beside tau <= time/3;
  • priorities 3/2, 2/3 and 0/0, with fromTrigger true or false;
  • the state pair. Across the 300 random slopes and thresholds above, it gives 0.15 for 300 of 300.

Tests

python/tests/test_strict_trigger_after_its_non_strict_twin.py has 29 tests. The oracles are closed forms: no reaction moves X, so X after the pair is 0.3·0.5 in sequence.

test main here
in sequence over 60 values of tau: 0.1*time, time/3, time - 1, 3*time fail pass
the same for bare time (control) pass pass
tau/7, tau + 0.1, reversed and mixed spellings fail pass
priorities 3/2, 2/3, 0/0, values frozen at the trigger fail pass
the same with fromTrigger=false, where a batch gives 0.15 too (control) pass pass
the held event reads the state the first left (Y = X reads 0.5, not 1) fail pass
priority orders each sub-instant: two >= of priority 1 and 2 and a > of 3 run 2, 1, 3 fail (3, 2, 1) pass
two strict triggers on one residual stay one batch (control) pass pass
two strict triggers keep their priority beside a third event on the same read, 321 (control) pass pass
the state-trigger pair fail pass
a delayed strict event freezes its value after the non-strict one fail pass
a pair true at the run's start is one batch, ODE and SSA (control) pass pass
a pair a piecewise jump turns true is one batch (control) pass pass
SSA: a pair a firing turns true is one batch (control) pass pass
a held trigger the first event turns false never fires fail (fires) pass
a sensitivity run refuses a held pair whose E1 reads E2's write, by name, and never returns the batch fail (returns 0.5) pass
a sensitivity run keeps a pair that touches nothing of the other (dA/dk = T, dY/dk = dZ/dk = 0) (control) pass pass
SSA, 0.1*time and time/3, in sequence fail pass

19 tests fail on main and pass here. The other 10 are controls that pass on both: they pin what the rule must leave alone.

Test audit

Checked against the test-writing rules Bill sent: each expected value's oracle, each tolerance's reason, and each test shown to fail against broken code. Commit 9aec8bc.

  • Tolerances. Every closed form was compared at pytest.approx's default rel 1e-6, about ten orders looser than the events' own arithmetic.
    • X is never integrated, and all 300 sequence runs give 0.15 to the bit, so they now share EXACT = 1e-12. The float == comparisons go through it too.
    • In the sensitivity test, dA/dk is compared at 1e-8, 100× the solver's rtol; it measures 6e-16 off. dY/dk and dZ/dk are compared at 1e-12; they measure 0.
    • The reasons are in comments beside each one.
  • Mutations, each rebuilt and run:
mutation caught by
the ODE drain holds nothing 17 tests, every ODE sequence test
ODE: skip the step-back crossing check the piecewise jump control
ODE: also ignore on_crossing the jump and run-start controls
a sensitivity run never refuses a held pair the refusal test
a sensitivity run always refuses a held pair the "keeps" test
the SSA drain holds nothing both SSA sequence tests
SSA: skip its crossing check the SSA firing control
a strict trigger pairs with any trigger on its residual nothing, at first

Two strict triggers held together are released together, which reads as one batch, so that last mutation went unseen. The new test adds an event that reads the same comparison inside an &&: it rises at the same read without being paired.

  • One batch: the digits come out 3, 2, 1 (321).
  • The mutation: 1, 3, 2 (132), for all 60 tau.
  • On main: it passes, as a control.
  • Bounds that depend on the run. The refusal test's refused > 0 is 57 of 60 here. The SSA control's X >= 3 fails a correct run with probability 3.6e-15.

Other checks:

  • Python suite (macOS arm64, 10 workers): 15,915 passed, 464 skipped, 1 xfailed. 1 failed: test_run_tests_sh.py::test_the_script_itself_runs_walk_up_dependent_tests_green, which fails the same way on main in this environment, because the venv has no jax, diffrax or roadrunner and the stand-in run reports their modules as skips.
  • parity_checks/tests: 910 passed, 49 skipped.
  • Native C++ suite (ctest, the native-tests.yml configuration): 6 of 6 pass.
  • Formatting: clang-format 17.0.6 and ruff 0.14.0 are clean.
  • perf_ab: benchmarks/perf_ab.py --quick, main aedadd8 against this branch (21 models up to 150 species, the ode and codegen workloads). No significant slowdown, and the solver's work counters are the same on all 42 rows. The geometric-mean ratios are 0.991 warm and 0.997 cold for ode, and 0.989 warm and 1.073 cold for codegen, where no model is flagged. None of the suite's networks has an event, so the changed drains are not reached. I stopped the full run for time.

Review

There was no second session to review this, so a separate review agent read the diff twice, read-only, given the issue and the diff but not the reasoning behind it.

  • First pass. Nothing lost or fired twice. Four semantic or scope issues were fixed here:

    • the hold caught jumps as well as crossings;
    • the sensitivity refusal was broader than needed;
    • a user's strict event could pair with a loader size jump;
    • the SSA setup compiled every residual.

    Also fixed: the wording of the docs, and the event id in the message. The run-start hold could fake a crossing; the twin's trigger is now read directly.

  • Second pass. "The state restore is complete in both engines. The narrowed refusal is enough. I found nothing newly broken." Its three remaining points are fixed: the step doubles where a residual moves by less than its rounding, the ODE reads the arriving trajectory from CVODE's interpolant, and the refusal reads the rescale sizes.

Not covered

  • An SSA pair on a rate-rule species. In continuous mode it moves between firings, but the SSA probe holds the state fixed, so such a pair stays one batch, as before.
  • Delays that round to nothing. In SSA a delayed execution that comes due at the instant drains before the held event. In ODE it runs in a later batch at the same time, as any such execution already does.
  • Not run here:
    • the SBML semantic suite gate and the Linux and Windows legs, which CI runs (no local copy of the suite was fetched);
    • a MIR build, though MIR does not touch the event drains.

…anl#947)

`lhs > tau` beside `lhs >= tau` turned true at one read wherever the read
landed past the crossing, and executed there as one batch by priority, but
in sequence where it landed on it. Which one a run gave depended on the
rounding of tau: on aedadd8, over 307 values of tau, `time/3` batched for 95
and `time - 1` for 103, and a state pair batched wherever CVODE's root
landed past the threshold. The SSA drain did the same.

In exact arithmetic the non-strict trigger is true on the crossing and the
strict one only past it. So where both turn true at one read on a crossing,
the strict one is held back to a second sub-instant of the same time. It is
triggered (trigger re-read, values and delay read) once everything else of
the instant has executed, at the state that left.

- NetworkModel::event_trigger_comparison() gives the comparison the trigger's
  residual comes from, oriented to rise where the trigger turns true, and
  whether it is strict. strict_beside_non_strict() pairs a rising set.
- process_firing_batch() in both engines holds such a pair only on a
  crossing: one step back along the run (CVODE's interpolant in the ODE, the
  time alone in SSA) the twin reads false and the residual moves as it moved
  over the step before. A pair a jump turns true (a firing, a piecewise that
  switches) stays one batch. The step doubles where the residual moves by
  less than its rounding. The run-start batch and size jumps are left alone.
- A sensitivity run keeps a held pair whose strict event reads and writes
  nothing the first sub-instant wrote, which the jump composes as one of the
  batch, and refuses the rest by name.
…rder (lanl#947)

An audit against the scientific-testing rules:
- Every closed form was compared at pytest.approx's default rel 1e-6, ten
  orders looser than the events' own arithmetic. X is never integrated, and
  all 300 sequence runs give 0.15 to the bit, so they now share EXACT = 1e-12,
  with the reason beside it. The float == comparisons (213, 0, (1, 2)) go
  through it too.
- The sensitivity test states its bounds: dA/dk at 1e-8, 100x the solver's
  1e-10 (measured 6e-16 off); dY/dk and dZ/dk at EXACT, measured 0.
- The refusal test's "refused > 0" is 57 of 60 here; the SSA test's X >= 3
  fails a correct run with probability 3.6e-15.

Mutation check, each rebuilt and run: holding nothing (ODE, SSA), skipping
either crossing check, ignoring on_crossing, and never or always refusing a
held pair are each caught. Pairing a strict trigger with any trigger on its
residual was not: two strict ones held together release as one batch. The new
test puts a third event beside them on the same read, which one batch orders
3, 2, 1 (321) and the mutation 1, 3, 2 (132, all 60 tau).

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

Development

Successfully merging this pull request may close these issues.

Events on time >= tau and time > tau fire as one batch or in sequence depending on the rounding of tau (X ends at 0.50 or 0.15)

1 participant