Repository navigation
dense: read a row the Newton matrix holds alone as its own equation - #1471
Merged
Merged
Conversation
Where row i of the Newton matrix has no nonzero off its diagonal, x_i is fixed by that row alone, b_i / a_ii, and is exactly 0 for a species held at 0. Partial pivoting does not see that: where column i has a larger entry in another row, that row becomes the pivot, and the back-solve computes x_i as the remainder of sums that cancel, leaving their rounding in it. On an exact state that is unstable, the residue grows. MODEL1812040005's P is a rate-rule target whose law is P*g: it starts at 0 and stays there, but g > 0 on t in [1.72, 9.53], where a departure grows by 3.9e26. V's rate reads sqrt(P), so P's column has a huge entry on V's row. In the file's order P's column comes last and P stays 0. Reversed (path agreement's `reorder`), V's row is the pivot. The residue left in P grew to 2.6e8 on x86-64, 25.6x the trajectory's peak. On arm64 it was negative, and #1340's band held P at -8e-12. KLU, whose block-triangular form takes such a row as a block of its own, keeps P at 0 in either order, and so does libRoadRunner. BIOMD0000000908's S = 0 (PR #1427) is the same case: reversed, S grew to 1.09e134 of the peak on the Linux nightly, and the run failed near t = 150 on macOS. The dense solver now notes those rows at each factorization, before the factor overwrites the matrix, in one branchless pass that vectorizes, and the solve reads each of them as b/a in place of what the back-solve left. A NaN off the diagonal is not a zero. On the built-in factor, every other component is SUNDIALS' dense LU's to the bit, and so is every component of a matrix with no such row; on the BLAS factor they are dgetrf's, as before. Species at 0 that feed each other, a chain or a cycle, are not covered: the pivoted LU still solves them on dense. bngsim's own dense solver (lapack_dense_linsol.cpp) now serves every dense route, the steady-state solves and the step check's scratch solve included. Without a BLAS backend, or where the BLAS factor is not asked for, it factors with the built-in GETRF only, as SUNLinSol_Dense does, and the GH #132 accessors still treat it as not the adaptive solver (-1, no reset). test_a_fast_loss_does_not_send_the_crossing_back_to_the_old_judgment pinned kf = 1e7, where #763's closer-than-a-step refusal turns on rounding: of 17 kf, main refused 3 on arm64, this branch 4 on arm64 and 4 on x86-64, not the same ones, and none gave the old judgment's answer. It now runs six kf that ran right on every build and that a mutant bringing the old judgment back gets wrong, and asks each for the right columns or that refusal by name.
…this PR moves test_model9811206584_runs_on_klu_as_on_dense held KLU to dense within 1e-5 of each species' peak. At the run's rtol of 1e-6 each route is up to 3.1e-5 off the model at rtol 1e-12 (where the two agree to 3e-12), so the bound measured how alike their rounding is: 3.1e-6 apart on arm64, and on x86-64, where this PR's dense solve lands nearer the tight run (9.8e-6 off it in r, KLU 2.7e-5), 1.8e-5 apart, which failed both Linux legs. Both routes are now held to the tight run within 1e-4 of the peak. Path agreement's run 41556140350 on this PR (x86, before #1472 and #961's steady_state merges), copied verbatim: - MODEL1812040005|reorder EXCEPTION -> PASS, BIOMD0000000908|reorder DIFF -> PASS: this PR's fix. - MODEL2001200002|klu_vs_dense, MODEL5952308332|klu_vs_dense EXCEPTION -> PASS: #1468's, which the last nightly predates; both PASS identically on main. - BIOMD0000000516|steady_vs_run SKIP -> PASS and BIOMD0000000510|steady_vs_run PASS -> SKIP (did not converge, residual 6.3e-9). Whether steady_state(method="integration") converges on these glycolysis models at rtol 1e-6 turns on the run's path: on main, 516 converges at rtol 1.05e-6 and not at 0.95e-6 or 1e-6; on this branch under x86-64, not at 0.9e-6 or 1.05e-6. Where it does not, the residual stalls at 3e-9 to 8e-9 against tol 1e-9. The run's other alert, MODEL1006230077|locale PASS -> TIMEOUT, is runner speed, which #1472 now lists in flaky.json. The tally is recomputed; replaying the run's verdicts against this baseline gives no alert.
This was referenced Nov 3, 2026
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.
Fixes the x86 path-agreement DIFF on
MODEL1812040005|reorder(25.6x the trajectory's peak, run 41491718656). It also fixes BIOMD0000000908's reversed run: silently wrong on the Linux nightly, a failure on macOS.What was silently wrong on main
CV_CONV_FAILUREat t = 145CV_CONV_FAILUREat t = 149.7In each model, the file's own order and KLU keep the 0 exactly. libRoadRunner keeps MODEL1812040005's P at 0 in both orders. It keeps BIOMD0000000908's S at 0 only in the file's order; reversed, its S reaches 6.8e17.
Why
MODEL1812040005's rate for P is P·g(P, V). With P(0) = 0 the solution is unique: P stays at exactly 0. The a·P^z in V's rate does not change that, because it feeds only V. But g > 0 on t ∈ [1.72, 9.53], at up to +9.4 per unit time, so P = 0 is unstable there: any departure grows by e^61.2 ≈ 3.9e26.
While P = 0, P's row of the Newton matrix has nothing off its diagonal, so x_P = b_P / a_PP = 0 exactly. V's rate reads √P, though, so P's column carries a huge entry on V's row.
P^0.5was NaN, and sbml: reversing listOfSpecies makes MODEL1812040005 fail (P below 0, a*P^z NaN) where the order written runs; RoadRunner runs both #1340's band read P as 0, holding it at −8e-12.KLU's block-triangular form takes such a row as a block of its own, which is why KLU never had this problem. BIOMD0000000908's S = 0 (S' = +0.277·S) is the same case. PR #1427 explained its
reorderDIFF as a fragile model's; the dense solve is what broke it.The fix
src/lapack_dense_linsol.cpp). That covers CVODE's, the steady-state solves and the step check's scratch solve.denseGETRS, asSUNLinSol_Densedoes.SolverStatsare unchanged.Evidence
Tests fail on main and pass here.
python/tests/test_dense_solve_reads_rows_held_alone.py(12 cases): 6 fail on main and all pass here, on arm64 and on x86-64 under Rosetta.tests/test_lapack_dense_linsol.cpp: both new row-held-alone cases fail on main (8/10). 10/10 pass here with Accelerate. WithBNGSIM_ENABLE_LAPACK_DENSE=OFF(from the review), the cases pass or skip by name, with no warnings.Path agreement on x86-64 (Rosetta build of this branch, with KLU):
MODEL1812040005|reorderis PASS at 1.3e-12 (baseline EXCEPTION, nightly run 41491718656 DIFF 25.6).BIOMD0000000908|reorderis PASS at 2.1e-13 (baseline DIFF 1.09e134).BIOMD0000000908|stablestays "contested", which is right: a start moved off 0 still grows.Corpus sweep, all 1,309 SBML ODE jobs of rr_parity, default route, main against this branch:
reordertransform; 86 models have too few species and reactions for it), counting runs that disagree with their file order by more than 1e-3 of the peak or fail only reversed: 13 on main, 12 here.CV_ERR_FAILUREat t = 7. Main's own run at a tenfold tighter tolerance fails the same way. Its GL rate has a pole at GL·(t − 7.2) = −85 that the trajectory crosses near t = 6, so past it no engine has an answer: GL is 1.2e11 in the file's order and 1.5e7 reversed on main. In libRoadRunner at the job's tolerances it is −5.1e6 at t = 7 and reaches −3.45e7 by t = 13.Performance (CPU time of
run(), alternating processes):Review
Round 1 (one sub-agent) found:
continueon a no-BLAS host, which the coverage guard refuses. It is now its own case withSKIP().test_a_fast_loss_does_not_send_the_crossing_back_to_the_old_judgment[1e7]hit The state-switch continuity test compares the rate-law jump with max|dx/dt| over every species, so one fast unrelated species (for example a large degrading pool) turns a real jump into "continuous". The saltation term is then dropped... #763's closer-than-a-step refusal. Whether it fires there turns on rounding: of 17 kf values, main refused 3 on arm64, and the branch refused 4 on arm64 and 4 on x86-64, not the same ones. No build ever gave the old judgment's wrong answer. The test now runs six kf values and asks each for the right columns or that refusal by name, and that at least four run.Round 2 (a fresh sub-agent) found no CRITICAL or HIGH issues. Its LOW findings were all fixed:
cvode_simulator.cppand the C++ test.It also mapped the #763 test's kf: below 7e7 the refusal flips between builds across a third to a half of kf. The test's six values are 8e7, 1e8, 7e8, 1e9, 1.3e9 and 5e9. They run right on arm64 main, the arm64 branch and the x86 branch. A mutant that brings the old judgment back fails the test: round 2 saw dW = (0, 3, 0) at 1e8, 1e9 and 5e9, and the test accepts no refusal but #763's closer-than-a-step one.
Round 2 also confirmed:
test_state_switch_sensitivity.pyon arm64 and x86;Round 3 checked the final text. It found two wording slips in this description, now fixed, and no defect in the code.
Left alone
Baseline
Path agreement ran on this PR (run 41556140350, x86-64, AMD EPYC 7763). Six rows are copied verbatim from its
verdicts.jsonintodifferential.json, and the tally is recomputed:MODEL1812040005|reorderEXCEPTION → PASS andBIOMD0000000908|reorderDIFF → PASS: this fix.MODEL2001200002|klu_vs_denseandMODEL5952308332|klu_vs_denseEXCEPTION → PASS: ode: KLU's colored difference quotient reads f through the RHS's non-finite retry #1468's, which the last nightly predates. Both pass identically on main.BIOMD0000000516|steady_vs_runSKIP → PASS andBIOMD0000000510|steady_vs_runPASS → SKIP ("did not converge, residual 6.3e-9"). On these glycolysis models, whethersteady_state(method="integration")converges at rtol 1e-6 turns on the run's path, on main as here:Several of their species are constant, so their rows of the Newton matrix are held alone. On main the LU leaks rounding into them: Rib5P_g goes from 0.01 to 0.009999999999999993, and Gly_e from 0 to 4.7e-21. That is why this fix moves these rows.
That run's other alert,
MODEL1006230077|localePASS → TIMEOUT, is runner speed. The model spends its whole cap; its base run takes the same 346 steps and 2.0 s on main and here. Every model ran 1.65x as long as on the previous nightly's EPYC 9V45. #1472 has since listed the row inflaky.json. Replaying the run's verdicts against this baseline gives no alert.Tests
python/tests/test_dense_solve_reads_rows_held_alone.py(new). Cases:tests/data/BIOMD0000000908.xml(dePillis2013) is new.tests/test_lapack_dense_linsol.cpp: three new cases.SUNLinSol_Dense's to the bit.python/tests/test_state_switch_sensitivity.py: the fast-loss case, as above.python/tests/test_colored_dq_reads_the_clamp.py:test_model9811206584_runs_on_klu_as_on_denseheld KLU to dense within 1e-5 of the peak. That failed both Linux legs. At rtol 1e-6, each route is up to 3.1e-5 from the model at rtol 1e-12, where the two routes agree to 3e-12. So the bound measured how alike the two routes' rounding is:r, against KLU's 2.7e-5).Both routes are now held to the tight run within 1e-4 of the peak. That passes on arm64 main, on the arm64 branch and under x86-64.
Full suite (
-n 6, arm64, merged with main at b1d5f7a): 17,423 passed and 1 failed,test_run_tests_sh, which fails in any scratch worktree and on main there too.