Repository navigation
Refuse a run carried to a pole instead of clamping it there (BIOMD0000000338) - #1354
Merged
Merged
Conversation
Where the right-hand side (or a closed-form Jacobian) comes back non-finite, GH #135 reads it again with every negative concentration set to 0. That is the boundary value for a species the solver's own error put a hair below 0: over the rr corpus the states it rescues sit 80 to 1.3e4 of the run's atol below 0, and move with atol. It also answered far from 0. BIOMD0000000338's XIIa and K activate each other through v*S*E/(1 + E), so XIIa = K = 0 is an exact, unstable solution with poles at -1. The dense LU's rounding tips XIIa below 0, the laws carry it to the pole, and there the clamp read 0 for XIIa and K and held both at -1. The dense run over 0..0.08 (arm64, d1b27d5) returned XIIa = -1, K = -0.997 with no error; before #1302 the x86-64 CI's run over 0..2 did the same, and agreed with KLU's exact XIIa = 0 closely enough to PASS the path-agreement check. #1302 made that x86 run fail, without saying why. The clamp now leaves alone a component more than 1e6 atols below 0, so the RHS stays CVODE's recoverable failure there. Where CVODE's own predicted state is as far below 0 in that component, the run has gone there, not just a Newton iterate: the callback records why and returns CVODE's unrecoverable code, and the run raises as a CVODE failure naming the concentration. A failed closed-form attempt still retries on the difference quotient (BIOMD0000000404). Steps past the run's last output time are exempt: CVODE steps beyond it and interpolates back. The #135 fixture moves from X = -1e-3 (1e7 atols, now refused, a new test) to -1e-5; #867's analytical failure names the right-hand side where S has drifted to -0.069.
Review round 1 found the band refusing correct runs: the first residual of every step is evaluated at the predicted state, so a predictor that overshoots past 1e6 atols, from a step that started near 0, met "far below 0 in the state and in the prediction" by construction. S -> P; 50*S^2.5/(0.01^2.5 + S^2.5) from S = 100 at bngsim's defaults is predicted to S = -9.7 and was refused, where main is right to 7e-9. Bounding the clamp on the evaluated state also failed finite-time extinctions (X' = -k X^a) whose accepted state stays near 0. The band is now judged at the state the step started from, CVODE's accepted y_n, shifted back from the predicted history array (the order found by asking for one more derivative until CV_BAD_K, whose message is dropped), and on the run's own scale: 1e6 atols plus 1e3 rtols of the largest value the slot has had at a reported row. A species emptied from X0 stops up to 8.2 rtols of X0 below 0 inside the run's tolerance, 8e7 atols at atol 1e-12. Slots the step started far below 0 are not read at 0; everything else is clamped as on main, so a run whose accepted state never goes far below 0 is main's to the bit. The run is refused where the value stays non-finite with those slots spared and turns finite once they too are read at 0, and the message names the spared slots that keep it non-finite on their own (a species a finite law carries below 0 beside them is no cause). The end-time exemption goes: an overshoot past the last output starts from a state near 0.
Review round 2 found the band still refusing correct runs: a scale folded in at reported rows misses a species that rises and empties between two rows (an event's dose, a pulse) and a run continued from an earlier run's in-tolerance undershoot, and the step-start reconstruction on every clamp fire doubled the cost of clamp-heavy runs (MODEL2001200002 13.7 -> 26.5 ms). Every one of those was a NaN: sqrt or a fractional power read a hair past 0, whose value at 0 is its own limit. Every run the change moved from an answer to a refusal was an infinite value: a pole (BIOMD0000000338's v*S*E/(1 + E) at -1, BIOMD0000000810's /(Interferon_gamma + k4), MODEL0995500644's 1/(1 + ump/K)). So the clamp now spares a slot only where the value first read is infinite, and only one the step started more than 1e6 atols below 0; a NaN is clamped as on main, at any depth, and the scale is gone. The step start is reconstructed only on an infinite value, which keeps the hot clamp loops main's to the bit and their cost. GH #135's fixture and #867's tests are main's again, unchanged. The refusal a read outside CVODE records (stop_would_lose) no longer stays on the run.
Review round 3 (F2): the trigger and the culprit were not tied. Any infinity anywhere spared every slot the step started far below 0, and one was refused if its own reading kept the value non-finite, NaN included: X, emptied through X^0.05 and only NaN below 0, was refused because exp(-0.01*(A/W - 1)) overflowed at another species W's undershoot, where main returns the run right. A far slot is now spared only where, with every other slot read at 0, its own reading is infinite; the rest are clamped as on main, and the message names the spared. The Jacobian advice is main's again (L1): it named a case only an infinity reaches.
Review round 3 (F1): exp(-1/X), 0 at X = 0, overflows to +inf for every X in (-1/709.8, 0), where an extinct species' in-tolerance undershoot can sit; read as a pole, the run was refused where main returns it right (40 of 450 configurations of round 3's scan; BIOMD0000000554's exp(-0.01*(A/X - 1)) form on KLU). A pole the run has landed on is infinite at that one point and finite a relative 2^-20 to either side; an overflow stays infinite. A candidate is now spared only where both of those reads are finite: two more evaluations per candidate slot, on a call that read an infinity at a step started far below 0, and none otherwise.
On x86-64 Linux CI, 404's closed-form attempt succeeds (within 3e-7 of the difference quotient), so no fallback runs; and 810's difference-quotient run crosses the pole of /(Interferon_gamma + k4) without landing on it, returning 9.9e15 on main and on this branch alike (issue #1339), where arm64 lands on the pole and is refused. The 404 test now holds the answer to the difference quotient's either way, and the 810 test to the property 338's tests hold: a refusal naming the species, or nothing returned 1e6 atols below 0.
The row was PASS because x86 main's dense run returned XIIa = -1, K = -0.997 (a pole the clamp parked it at) close enough to KLU's exact XIIa = 0. Since #1302 that run fails on x86, and this branch refuses it by name. The record is copied verbatim from main's scheduled nightly, run 40712357505 at 9a26f5b (nightly-differential, verdicts.json), with the tally moved by the one row; no other row changes. verdicts.py diff of this baseline against that run's verdicts: no regressions.
wshlavacek
added a commit
that referenced
this pull request
Oct 28, 2026
#1354 re-baselined BIOMD0000000338|klu_vs_dense to the EXCEPTION row of run 40712357505; this branch already carries that run's verdicts.json whole, so its file is kept.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Path-agreement's
BIOMD0000000338|klu_vs_densewent from PASS to EXCEPTION on x86-64 CI after #1302 (run 40565116208). That PASS was two answers agreeing by accident, and one of them was silently wrong. This PR refuses the wrong one by name and re-baselines the row.What was silently wrong on main
GH #135: where the right-hand side (or a closed-form Jacobian) comes back non-finite, bngsim evaluates it again with every negative concentration set to 0. That is the right value for
sqrtor a fractional power read a hair below 0, whose value at 0 is their own limit. It is wrong at a pole.BIOMD0000000338 (Wajima 2009) has XIIa and K activating each other through
v·S·E/(1 + E)with k = 1. So XIIa = K = 0 is an exact, unstable solution, and each species has a pole at −1. The dense LU's rounding tips XIIa below 0, and the model's own laws carry it to the pole. There the right-hand side is −inf, the clamp read XIIa and K at 0, and that held both at −1. The run returned them with no error. KLU keeps XIIa exactly 0, which is the exact solution.jacobian="fd", t 0..0.08jacobian="auto", t 0..0.08jacobian="analytical", t 0..0.08jacobian="fd", t 0..2 (the CI's run)jacobian="fd", t 0..2On arm64, BIOMD0000000810 with
jacobian="fd"does the same thing at the pole of/(Interferon_gamma + k4). There main returned a cell of 3.3·10²⁶ where the default run, which agrees with RoadRunner, has 3.4. It is now refused, naming Interferon_gamma. On x86-64 the same run crosses that pole without landing on it, and returns 9.9·10¹⁵ on main and on this PR alike (under Left alone).The fix (
src/cvode_simulator.cpp)spare_at_polelooks at the state the solver's step started from. That is CVODE's accepted y_n, recovered exactly from the predicted history array as Σ_k Dky(tn, k)·(−h)^k/k!. CVODE does not report the order mid-step, so it is found by asking for one more derivative until CV_BAD_K, and that log line is dropped. A slot is left unclamped (spared) if three things hold:exp(−1/S)stays infinite over a stretch (round 3).sqrt, fractional powers, logarithms read below 0. So does an infinity with nothing spared. GH Extract the two forward-sensitivity jump handlers out of CvodeSimulator::run (follow-up to #109) #135's and cvodes: an analytical Jacobian still non-finite after the GH #135 clamp is used anyway, and silently freezes that species' Newton correction #867's test files are main's, unchanged.dq_side_point(Difference quotients that step across a jump in a rate law (#1002) #1302's side probes) andstop_would_losedon't let a refusal they record stay on the run, and the warm path clears it per run.Extra right-hand-side evaluations
None on the ordinary path. The check runs only on a call that read an infinity at a step that started more than 10⁶ atols below 0, and only for a slot whose own reading is infinite. It then re-reads that callback's function once or twice:
Counted on arm64 with an instrumented build:
"fd""analytical""auto""fd"retry)"fd"exp(−1/X)extinction, auto / fd / KLUWall time on clamp-heavy runs that never meet an infinity is main's. Interleaved best of 7, three rounds, arm64, identical results:
The difference is the
isinfscan of a clamped evaluation.Census (arm64, main d1b27d5 against 40d7ada)
The rr ODE corpus, 1,323 models at each job's own window and tolerances, in three modes: default,
jacobian="fd", and dense"fd"(dense capped at 200 species, as the path-agreement harness caps it)."fd"and dense"fd"(main: max 3.29·10²⁶)exp(−c/S)law, are bit-identical to main at their own jobs on all 8 paths tried: auto, fd, analytical, dense, dense fd, KLU, KLU fd, codegen. They are also identical without the 2⁻²⁰ re-read.Review
Three rounds, each by an independent reviewer on a frozen build: read-only, filing nothing.
Round 1 (4566562): a HIGH, false refusals of correct runs. The first rule judged "far below 0" against CVODE's predicted state, and the first Newton residual of every step sits exactly there. So a predictor overshooting from a step that started near 0 was refused. Example:
S → P; 50·S^2.5/(0.01^2.5 + S^2.5)from S = 100 at bngsim's defaults, where main is right to 7e-9. Bounding the clamp itself also broke extinctions (X' = −k·X^a). Also a LOW: the message named the wrong species. Fixed by judging at the step's accepted start and naming only the cause (bbab179).Round 2 (bbab179): three HIGHs from judging depth against a tracked scale:
Every one was a NaN case, and every case the change moved was an infinity at a pole. So the rule was narrowed to poles, with NaN taking main's path (df42f74).
Round 3 (df42f74), the last round, named in advance:
exp(−c/S)overflows to +inf at an extinct species' in-tolerance undershoot and was taken for a pole. Fixed by the 2⁻²⁰ re-read, by the maintainer's choice (40d7ada).Left alone
jacobian="fd"on x86-64 is one: it returns 9.9·10¹⁵ on both builds (Rosetta), where arm64 lands on the pole and is now refused. One species,X' = 10·X/(1+X) − 1e-6from X = 0, reaches its pole at t* = 1.512. Both builds return X(3) = −1.96e16 ("fd"), +2.33e41 ("analytical", default) and −1.6e6 to −1.9e6 (KLU) on arm64, with no error; x86-64 is the same in kind. Nothing non-finite is ever evaluated, so no clamp rule can see it.X → ; 1with→ Y; sqrt(X)from X = 1 returns X(2) = −1, Y(2) = 2/3 with no error. X goes below 0 by the model's own constant sink, andsqrt(X)has no value past t = 1.X' = −k·X^0.05from X0 = 10 at rtol 1e-6, atol 1e-9 returns X frozen at −0.125, 1.25·10⁴ rtols off.10·S·E/(1+E) − E/(1+E)split into two reactions, so that E's row is inf − inf. Round 3's N1.Tests
python/tests/test_clamp_far_below_zero.py(new, 10 tests)."fd","auto"and"analytical"over 0..0.08, each either refused naming XIIa (and not K) or returning nothing 10⁶ atols below 0."fd"and returns its answer bit for bit (arm64); on x86-64 the attempt succeeds, within 3e-7 of it;"fd"is refused naming Interferon_gamma (arm64) or returns nothing below 0 (x86-64, where it crosses the pole);tests/data/BIOMD0000000338.xml,BIOMD0000000404.xml,BIOMD0000000810.xml(BioModels, CC0).parity_checks/nightly/baselines/differential.json:BIOMD0000000338|klu_vs_densegoes PASS → EXCEPTION, and its tally moves by that one row; no other row changes.nightly-differential,verdicts.json), where it was that night's one alert.verdicts.py diffof the new baseline against that run's verdicts reports no regressions. It reports the same with 338's note replaced by this PR's named refusal (EXCEPTION either way)."fd"run crosses the pole on both builds.Local runs
python/testswith-n 6on the merged branch: 15,962 passed, 435 skipped, 1 xfailed. 2 failed:test_run_tests_sh: the known environment-only failure;test_cache_cli's prune test: a race under xdist, which passes 3 of 3 alone.test_rhs_nonneg_clamp,test_nonneg_clamp_scope,test_nonfinite_jacobian_after_clamp,test_nonfinite_diagnostic,test_difference_quotient_jump,test_sens_numerically_zero_base, and the new file): 110 passed."fd","auto"and"analytical"are refused naming XIIa, and KLU is bit for bit.ci/changelog.py check.