Skip to content

sens: read a row where the run stands when CVODES returns tout from a step that does not span it (#1301) - #1343

Merged
wshlavacek merged 7 commits into
lanl:mainfrom
Montana:fix/1301-sens-reads-outside-step
Oct 29, 2026
Merged

wshlavacek merged 7 commits into
lanl:mainfrom
Montana:fix/1301-sens-reads-outside-step

Conversation

@Montana

@Montana Montana commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #1301.

What was wrong

The issue's two messages are one defect, and the second is not a stale handle.

1. CVODES returned an output time from a step that does not span it. After the sensitivity RHS refuses a state (GH #395's recoverable return), a CV_NORMAL call can return tout from a last step that starts after it. Traced on the issue's MPEKRIS fixture, at row 44: one call from tn = 43.984 took 3 steps, ended at 44.0311 with a last step from 44.0099, and returned flag 0, t_ret = 44. CVode then interpolates to tout with (void) CVodeGetDky(...), ignoring its result. So the refusal ("Illegal value for t") left y as the call before had it, and the row took that state, at the time asked for.

2. The row's sensitivities were left at zero. The per-row CVodeGetSens interpolates at CVODES's last return time, which is that same tout, so it failed too. The output loop had no else, so 24 rows of 101 came back with all-zero sensitivities, and no error. On the plain-run comparison, those are rows 44, 47, 49–52, 56, … 98.

3. The "solver at t = 2.6e-312" is a SUNDIALS message bug. In SUNDIALS 7.2.1, CVodeGetSensDky1 calls cvProcessError(…, MSGCV_BAD_T), a format with three %g fields, with no arguments. CVodeGetDky passes t, tn - hu, tn. The denormals are whatever was in the registers.

Either recovery switch removes it (BNGSIM_SENS_NONFINITE_RECOVER=0, BNGSIM_SENS_CLAMP_NUMERIC_ZERO=0: 0 bad reads instead of 48), which confirms the trigger.

The fix (cvode_simulator.cpp)

  1. Detect it. After a CV_NORMAL return of t_ret == tout in a sensitivity run, check whether tout is inside the last step [tn − hu, tn], with the fuzz CVodeGetDky itself allows (inside_last_step).
  2. Read the solver where it stands. If tout is outside, take the state at tn itself, which is always readable, and set t_ret = tn. A sensitivity run already records each row at the time it stands at ("A run with sensitivity columns keeps the time it stands at"), so the row is labelled honestly. Each such row is counted in solver_stats["n_rows_outside_last_step"].
  3. Read the row's sensitivities at its own time, with CVodeGetSensDky(t_ret) instead of CVodeGetSens, which reads at CVODES's stale return time.
  4. Never zero silently. A row whose sensitivities still cannot be read now raises, naming the row and the flag, instead of recording 0.
-                flag = CVode(cvode_mem, look_tout, y, &t_ret,
-                             (one_step || first_alone) ? CV_ONE_STEP : CV_NORMAL);
+                const int itask = (one_step || first_alone) ? CV_ONE_STEP : CV_NORMAL;
+                flag = CVode(cvode_mem, look_tout, y, &t_ret, itask);
+                if (flag == CV_SUCCESS && itask == CV_NORMAL && sens.n_total > 0 &&
+                    t_ret == look_tout) {
+                    ... if (!inside_last_step(look_tout, tn_now, hu_now)) {
+                        CVodeGetDky(cvode_mem, tn_now, 0, y);   // the state where the run stands
+                        t_ret = tn_now;
+                        ++rows_outside_last_step;
+                    }
+                }
 ...
-            flag = CVodeGetSens(cvode_mem, &t_ret, yS_guard.arr);
+            flag = CVodeGetSensDky(cvode_mem, t_ret, 0, yS_guard.arr);
+            if (flag != CV_SUCCESS) { throw ... }   // never the zeros the buffer starts as

CVODES still prints its own CVodeGetDky message from inside CVode, once per such row. bngsim cannot stop it attempting the interpolation, only stop trusting it. The CVodeGetSensDky1 messages are gone.

Verified

MPEKRIS (sensitivity_params=["sigma_nk", "g_reg"], rtol 1e-9):

main this PR
rows with all-zero sensitivities (t > 0) 24 0
n_rows_outside_last_step — 25
SUNDIALS messages 48 25 (CVODES's own, in CVode)

The rows are right, not just non-zero. Each moved row's deviation from a spline through its neighbours is, for sensitivities, 7.2e-4 against 2.0e-6 median and 1.4e-3 max elsewhere; for the state, 1.8e-4 against a 1.4e-3 max. That is within the run's own range, where main's were zeros. By construction a moved row's state is CVODES's solution at tn; on main it was the previous call's state at 43.98, labelled 44.

MODEL2001200002 itself:

Not this PR's: the sensitivity run's state differs from a plain run by about 3e-3 from t ≈ 41 on, including rows with no bad read. That is the clamp-retry trajectory itself, #1298's domain, which #1300 addresses.

Tests (python/tests/test_sens_rows_outside_last_step.py)

On MPEKRIS, copied from #1300's test:

  • the guard: the run reaches the path (n_rows_outside_last_step > 0). sens: evaluate the sensitivity RHS at the value RHS's clamped state (#1298) #1300 stops this model reaching it, so that would make the tests below pass for nothing; this test fails instead and asks for another model;
  • no row of sensitivities is left at zero, and all are finite;
  • each row is read where the run stands: at or past its grid time, within one interval, in order;
  • a run without sensitivities counts none and keeps the grid exactly.

On main: 3 fail, 1 passes (the in-order times).

Mutations, each rebuilt and caught:

  • the window check off: the run raises at t = 44 ("CVODES could not read the sensitivities at the output point t=44 … flag -25");
  • the per-row read back to CVodeGetSens: the same.

Suites: python/tests and parity_checks/tests give 17,326 passed, 172 skipped, 1 xfailed, every event and sensitivity test included. clang-format 17.0.6 and ruff 0.14.0 are clean. The core stub gains the new SolverStats field.

Not run: a sanitizer build. The issue's memory-safety question is answered by reading SUNDIALS's source: point 3 above. No second cvode_mem is involved.

… step that does not span it (lanl#1301)

After the sensitivity RHS refused a state (GH lanl#395's recoverable
return), CVODES could return tout from a CV_NORMAL call whose last step
does not span it: its CVodeGetDky refused ("Illegal value for t"), which
CVode ignores, so `y` was left as the call before had it and the row
took that state at the time asked for. The per-row CVodeGetSens then
failed too, and the row's sensitivities were left at zero, with no
error: 24 rows of 101 on MODEL2001200002 (Mpekris 2017). Its second
message, a solver "at t = 2.6e-312", is SUNDIALS 7.2.1's
CVodeGetSensDky1 printing MSGCV_BAD_T with no arguments, not a stale
handle.

Where tout lies outside the last step, the row is read at tn, as a
sensitivity run records its rows, and counted in
solver_stats["n_rows_outside_last_step"]. The per-row read is
CVodeGetSensDky at the row's own time (CVodeGetSens reads at the time
CVODES last returned, which there is the one asked for), and a row
whose sensitivities cannot be read raises instead of reading 0. The
same model on m_T8, which failed with CV_ILL_INPUT at t = 91 and
finished only on the GH lanl#176 FD retry, runs on its own Jacobian.
…tep does not span (lanl#1301)

The rows the issue saw come from a SUNDIALS defect, not from the output
loop. In cvStep's staggered sensitivity corrector CVODES evaluates f at
the converged state, and a recoverable failure there goes back round the
step loop with neither cvRestore nor a smaller step. The next attempt
predicts again from the advanced tn, so the step covers 2h and its first
h is crossed by the uncorrected predictor, which neither the error test
nor the root search sees. CV_NORMAL then returned tout from a last step
that does not span it. Reading those rows where the run stood left
everything else as wrong as before: on MPEKRIS the state was 9.0e-3 off a
tight plain run and the sensitivities 5.2e-3 / 1.4e-2 off central
differences. On a 10,001-point grid, 2,870 rows shared a time and stood
up to 0.094 past the time asked for. The bug is still in SUNDIALS main.

- third_party/sundials/carry_cvodes_staggered_restore.cmake applies the
  fix as the FetchContent PATCH_COMMAND: the failure goes through
  cvHandleNFlag as RHSFUNC_RECVR, like every other recoverable failure
  in cvStep. The configure fails if the fetched source lacks the marker,
  and VENDOR.json lists the carry, which vendor_sundials.py now keeps.
  BNGSIM_SUNDIALS_STAGGERED_RESTORE=0 takes the upstream path, for an
  A/B only. On MPEKRIS: state 3.4e-8, sensitivities 3.1e-6 / 2.3e-6,
  every row on the grid, 1,577 steps instead of 8,283.
- A CV_NORMAL return whose last step does not span tout is refused
  instead of being read where the run stands. The check now also covers
  the CV_TOO_MUCH_WORK retry's return, and it mirrors CVodeGetDky's own
  window (the hu < 0 fuzz was inverted). The helper moved out of
  retry_while_advancing's doc comment. A system SUNDIALS build warns at
  configure time.
- n_rows_outside_last_step is dropped: a returned run would always read 0.
- The per-row CVodeGetSensDky read and its refusal stay.
- The tests compare against independent references (a tight plain run,
  central differences) and the grid itself, and use the hatch to show
  that the model still reaches the defect. All 5 fail on main and on the
  PR as submitted.
…the marker (lanl#1301)

cvStep resets ncf to 0 just above the refused f call on every attempt, so
counted there a refusal that a smaller step does not cure never reached
maxncf: on MODEL2001140002 the step shrank to h ~ 1e-16 inside one CVode
call and the run ignored its own timeout (as it does on main, through the
upstream path). ncfS is kept across a step's attempts. The run now fails
with the GH lanl#54 collapsed-step message, and the wall-clock budget ends the
FD retry.

The marker is now "r2": the patch step is idempotent on the marker, so a
build tree holding the first revision is refused at configure with the
instruction to delete its _deps/sundials-* directories.

Swept over the 1,323 rr_parity ODE models with three parameters each: three
reach the upstream path (MODEL2001200002, MODEL2001160002, MODEL2001140002).
…n filter (lanl#1301)

From an independent review of the final head.

- A row recorded before the first step (a start time named twice:
  sample_times=[2, 2, 3]) read CVODES's interpolation where it has no
  step size yet, which divides 0 by 0: the per-row read refused the nan,
  and main, reading at another time, recorded dA/dA0 = 0 instead of 1 with
  no error. With no step taken in the run and no restart, the row now
  records the seed row 0 recorded. After a restart the read is left to
  CVODES, which refuses the nan as before.
- The finite-difference test picked columns at 1e-8 of the largest, where
  a column the parameter does not reach (Treg for sigma_nk) is the
  difference's own noise, within a factor of 10 of the cut and moving with
  libm. The cut is 1e-6 now; the PR as submitted still fails it (5.2e-3).
- The refusal also compares t_ret with t_target, the retry's tout.

All 6 tests fail on the PR as submitted and pass here; the suite passes
(17,753 passed, 167 skipped).
@wshlavacek

Copy link
Copy Markdown
Collaborator

Thanks for the trace: "a last step from 44.0099" was the thread that led to the cause. I pushed review commits on top of a merge of main (7eab654, 7649533, e9e1903, 8e334e5). They replace the row move, because the root cause is in SUNDIALS, not in the output loop.

The cause

In cvStep's staggered sensitivity corrector, CVODES evaluates f at the converged state. A recoverable failure there goes back round the step loop as nflag = PREV_CONV_FAIL; continue;, with neither cvRestore nor a smaller step. The next attempt predicts again from the advanced tn, so the step covers 2h and its first h is crossed by the uncorrected predictor. Neither the error test nor the root search sees that first h. That is how a CV_NORMAL return ends up with a last step that starts after tout. On MPEKRIS a probe build counted 2,333 of these in one run, starting at t ≈ 39.9. The code is unchanged on SUNDIALS develop; the fix and a unit test are now proposed upstream as llnl/sundials#1073 (and the same pattern in IDAS as llnl/sundials#1074).

What the move left

Reading a row where the run stands fixed the zeros, but the values stayed wrong. Against independent references (a plain run at rtol 1e-12, and central differences of plain runs):

MPEKRIS, rtol 1e-9 this PR as submitted with the SUNDIALS fix
state, max error (column-scaled) 9.0e-3 3.4e-8
d/dsigma_nk, d/dg_reg 5.2e-3, 1.4e-2 3.1e-6, 2.3e-6
rows off their grid time 25 0
steps 8,283 1,577

On a 10,001-point grid the move put 2,870 rows on repeated times, up to 0.094 past the time each asked for: once the run stood past an output, the next outputs were recorded where it stood. The "3e-3 from t ≈ 41" in the body is this defect, not #1298. The spline check compared each row with neighbours that were just as wrong.

The review commits

  • third_party/sundials/carry_cvodes_staggered_restore.cmake is applied as the FetchContent PATCH_COMMAND. It sends that failure through cvHandleNFlag as RHSFUNC_RECVR, like every other recoverable failure in cvStep, and counts it in ncfS. A first revision counted it in ncf, which cvStep resets on every attempt, so a refusal no smaller step cures never reached maxncf. The configure fails if the fetched source lacks the current marker (r2), which also catches a build tree holding the older revision. VENDOR.json → local_carries records the carry and its upstream PR, and vendor_sundials.py now keeps it across a refresh. BNGSIM_SUNDIALS_STAGGERED_RESTORE=0 takes the upstream path, for an A/B only.
  • A CV_NORMAL return whose last step does not span tout is refused, not read where the run stands. The check now also runs after retry_while_advancing's CV_NORMAL call, which had none. It mirrors CVodeGetDky's own window: the hu < 0 fuzz was inverted, though no reachable path takes it. The helper moved out of retry_while_advancing's doc comment. A system-SUNDIALS build warns at configure time.
  • n_rows_outside_last_step is dropped, since a returned run would always read 0.
  • Your per-row CVodeGetSensDky read and its refusal stay. One case needed more: a row recorded before the first step, such as a start time named twice (sample_times=[2, 2, 3]). There CVODES has no step size yet, and its interpolation divides 0 by 0. That row now records the seed row 0 records. Main recorded dA/dA0 = 0 there instead of 1, with no error.
  • Tests (test_sens_staggered_corrector_restore.py): the state against a tight plain run, the sensitivities against central differences, rows exactly on the grid (101 and 2,001 points), the hatch to show the model still reaches the defect, and the repeated start time. All 6 fail on the PR as submitted and pass with the fix. The finite-difference check leaves out columns below 1e-6 of the largest, so a column the parameter does not reach (Treg for sigma_nk, whose difference is libm-dependent noise) cannot get in.

Blast radius. A probe build counted the upstream path over all 1,323 rr_parity ODE models (3 parameters each, job tolerances). Three reach it: MODEL2001200002, MODEL2001160002 and MODEL2001140002.

Suites: python/tests + parity_checks/tests, 17,753 passed, 167 skipped, 0 failed; C++ ctest 6/6 from a fresh fetch, which ran the patch step on a clean download.

@wshlavacek
wshlavacek merged commit 819169c into lanl:main Oct 29, 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

Development

Successfully merging this pull request may close these issues.

sens: after a sensitivity-RHS refusal, the output loop reads the integrator outside its step (one read on a solver at t = 2.6e-312)

2 participants