fix(mm): stable tQSSA free-substrate root and subtraction-free partials (#89) - #92
Merged
Conversation
…ls (#89) The Michaelis-Menten free substrate was computed as delta = S - Km - E D = sqrt(delta*delta + 4*Km*S) sFree = 0.5*(delta + D) When delta < 0 that last line subtracts two nearly-equal positive numbers and loses about two significant digits per decade of |delta|/sqrt(4*Km*S): 8 correct digits at ratio 1e4, none at 1e8. This is in the RHS, so a model in deep enzyme excess had a wrong trajectory before it had a wrong gradient, and every derivative inherited it. sFree is the positive root of x^2 - delta*x - Km*S = 0, so the delta < 0 branch now uses the conjugate form 2*Km*S/(D - delta), which multiplies out to the same value with no subtraction. delta >= 0 keeps the textbook expression bit-for-bit. Fixing the root was necessary and not sufficient. The derivatives were the chain rule through sFree, dsF/dE = (-1 - delta/D)/2 dsF/dS = ( 1 + (delta + 2*Km)/D)/2 dsF/dKm = (-1 + (2*S - delta)/D)/2 and those cancel in exactly the same regime, so d(rate)/dE stayed at 1e+10 relative error with a correct sFree in hand. On the stiff fixture the cancellation went past magnitude and flipped the sign: -3.83e-05 where the true value is +4.96e-15. A negative d(rate)/dE says adding enzyme slows the reaction, and that entry drives CVODE's Newton step, so it steps confidently the wrong way rather than merely slowly. Differentiating the symmetric form of the rate collapses each partial to one subtraction-free quotient. The tQSSA complex is c = (A - D)/2 with A = E + S + Km, and that same D is sqrt(A^2 - 4*E*S), so dc/dE = (S - c)/D with S - c = sFree: d(rate)/dE = kcat*stat*sFree/D d(rate)/dS = kcat*stat*E*Km/((Km + sFree)*D) d(rate)/dKm = -rate/D Each is identical to sympy.diff of the shipped rate. Measured against mpmath at 60 digits over the four regime sweeps from the issue, worst relative error in float64, before -> after: sweep sFree rate d/dE uniform O(1) 4.5e-14 -> 4.1e-16 4.3e-14 -> 4.9e-16 5.6e-13 -> 6.1e-16 log-spread 6.2e-04 -> 3.5e-15 6.2e-04 -> 5.8e-16 3.0e+03 -> 1.8e-15 Km >> S,E 2.6e-09 -> 5.4e-16 2.6e-09 -> 6.5e-16 2.6e-09 -> 7.6e-16 Km << S,E 2.1e-02 -> 2.4e-13 2.1e-02 -> 5.1e-16 2.3e+09 -> 4.3e-14 sweep d/dS d/dKm uniform O(1) 2.6e-15 -> 7.3e-16 1.4e-12 -> 7.6e-16 log-spread 2.5e-10 -> 6.1e-15 2.6e+03 -> 2.4e-15 Km >> S,E 7.6e-16 -> 4.7e-16 9.4e-09 -> 7.9e-16 Km << S,E 5.3e-05 -> 4.7e-13 8.7e+09 -> 2.3e-13 The three lines were open-coded at every site rather than shared, which is why the issue asks for a grep before starting. They are now emitted from one helper per language: mm_tqssa() in include/bngsim/mm_jacobian.hpp, which compute_rxn_rate and mm_tqssa_derivatives both call, and _mm_sfree_c_lines() in python/bngsim/_codegen.py, which every emitter calls. Covered: the interpreted RHS and the SSA propensity emitter (src/model.cpp), the closed-form analytical Jacobian shared by the dense and sparse CVODE paths, both compiled-RHS emitters, the analytical Jacobian and its fused J*v, #55's analytic df/dp, and the JAX RHS (where the denominator is masked before the divide so the unselected branch cannot inject a NaN into a tangent). _CODEGEN_VERSION -> 26: MM models' emitted C changes, and a cached v25 .so would keep serving pre-fix numbers. The corpus cannot gate this. Both MM models (test_MM, mCaMKII_Ca_Spike) do reach delta < 0, so they take the new branch, but at |delta|/sqrt(4*Km*S) <= 4.95 and <= 1.32, where the two forms differ by 1-3 ulp — largest sFree shift measured over their trajectories is 1.9e-16 and 6.0e-16. generate_rhs_c is byte-identical on 120 sampled non-MM corpus models and differs only on those two. So the regression fixture is a deliberately stiff one, tests/data/mm_tqssa_stiff.net at ratio ~1e7, shared by the C++ and Python suites. Both halves are independently gated. Reverting only the root fails test_mm_tqssa_stiff_root at rel=0.0199; reverting only the derivatives fails it at rel=1.36e10, with the sign assertion on its own line so the sharpest symptom is the first thing to fail. TestNumericalStability pins each rejected form against mpmath — the textbook root, the chain rule through sFree, and #55's already-rejected "simplified" d/dKm — so a future tidy-up fails there instead of silently in a trajectory or a gradient. TestTheEmittedCAtAStiffRatio compiles the emitted RHS and df/dp and checks them against mpmath rather than against a finite difference of themselves, which is blind to a cancellation both sides share. Not touched: _mm_formula in python/bngsim/convert/_sbml_writer.py still exports the cancelling form. Fixing it means a piecewise() in the exported MathML, which changes the shape of every exported MM kinetic law and touches both the symbolic round-trip check and downstream consumers; that is its own change, not a rider on this one.
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.
Closes #89.
The bug, and the half of it the issue did not have
The free substrate was
sFree = 0.5*(delta + D)withdelta = S - Km - EandD = sqrt(delta^2 + 4*Km*S). Fordelta < 0that subtracts two nearly-equalpositive numbers and loses ~2 significant digits per decade of
|delta|/sqrt(4*Km*S)— no correct digit by 1e8.sFreeis the positive root ofx^2 - delta*x - Km*S = 0, so thedelta < 0branch is now the conjugate form2*Km*S/(D - delta).delta >= 0keeps the textbook expression bit-for-bit.Applying only that — the fix as written in the issue — is not enough, and the
measurement is what says so. The derivatives were the chain rule through
sFree(
dsF/dE = (-1 - delta/D)/2, and similarly forSandKm), and those cancel inexactly the same regime. With a correct
sFreein hand,d(rate)/dEstillsat at 1e+10 relative error.
On the regression fixture the cancellation goes past magnitude and flips the
sign:
d(rate)/dEcomes out-3.83e-05where the true value is+4.96e-15. Anegative
d(rate)/dEsays adding enzyme slows the reaction, and that entry drivesCVODE's Newton step — so it steps confidently the wrong way rather than merely
converging slowly. The C++ test asserts that sign on its own line, so the sharpest
symptom is the first thing to fail.
The forms that ship
Differentiating the symmetric form of the rate collapses each partial to a
single subtraction-free quotient. The tQSSA complex is
c = (A - D)/2withA = E + S + Km, and that sameDissqrt(A^2 - 4*E*S), sodc/dE = (S - c)/Dwith
S - c = sFree:Each is identical to
sympy.diffof the shipped rate (asserted in the suite, notjust checked once). Measured against mpmath at 60 digits over the four regime
sweeps from the issue — worst relative error in float64, before → after:
sFreerated/dEd/dSd/dKmSites
The three lines were open-coded at every site rather than shared, which is why the
issue asks for a grep first. They now come from one helper per language:
mm_tqssa()ininclude/bngsim/mm_jacobian.hpp, whichcompute_rxn_rateandmm_tqssa_derivativesboth call, and_mm_sfree_c_lines()inpython/bngsim/_codegen.py, which every emitter calls.Covered: the interpreted RHS and the SSA propensity emitter (
src/model.cpp), theclosed-form analytical Jacobian shared by the dense and sparse CVODE paths, both
compiled-RHS emitters, the analytical Jacobian and its fused
J*v, #55's analyticdf/dp, and the JAX RHS (_jax_rhs.py, where the denominator is masked beforethe divide so the unselected branch cannot inject a NaN into a tangent).
_CODEGEN_VERSION→ 26: MM models' emitted C changes, and a cached v25.sowouldkeep serving pre-fix numbers.
Why the fixture is synthetic
The corpus cannot gate this. Both MM models (
test_MM,mCaMKII_Ca_Spike) doreach
delta < 0, so they take the new branch — but at|delta|/sqrt(4*Km*S) <= 4.95and<= 1.32, where the two forms differ by 1–3ulp. Largest
sFreeshift measured over their trajectories: 1.9e-16 and6.0e-16.
generate_rhs_cis byte-identical on 120 sampled non-MM corpus modelsand differs only on those two.
So the regression fixture is a deliberately stiff one,
tests/data/mm_tqssa_stiff.netat ratio ~1e7 (large enzyme excess, smallKm*S),shared by the C++ and Python suites.
Verification
ctest), 2773 passed / 89 skipped Python, pre-commit clean.test_mm_tqssa_stiff_rootatrel=0.0199; reverting only the derivatives failsit at
rel=1.36e10.TestNumericalStabilitypins each rejected form against mpmath — the textbookroot, the chain rule through
sFree, and Analytic sensitivity RHS for Functional rate laws (df/dp), so forward sensitivity leaves the CVODES difference-quotient path #55's already-rejected "simplified"d/dKm— so a future tidy-up fails there instead of silently in a trajectory ora gradient.
TestTheEmittedCAtAStiffRatiocompiles the emitted RHS anddf/dpand checksthem against mpmath rather than against a finite difference of themselves. That
matters: a self-FD check is blind to a cancellation both sides share, which is
how Analytic sensitivity RHS for Functional rate laws (df/dp), so forward sensitivity leaves the CVODES difference-quotient path #55's tests passed over a broken rate.
against 2.1e-2 before.
The SBML exporter is a required companion, not an optional follow-up
_mm_formulainpython/bngsim/convert/_sbml_writer.pystill exports thecancelling form, and this PR is what makes that matter: moving the engine to
the stable root while the exporter keeps
0.5*(delta + D)means an exported modelno longer agrees with the engine that exported it. Measured on this branch with
validate_conversion(..., direction="net2sbml"):mm_tqssa.net(ratio ~1)Δexactly 0.0mm_tqssa_stiff.net(ratio ~1e7)Δ = 2.17e-02 > 1e-06So nothing that exists today regresses — the corpus MM models sit at ratio ~1 and
round-trip bit-for-bit — but a user model in the stiff regime now gets an SBML
export that is 2% off its own source, and the gate that catches it
(
RHS_FAITHFUL_TOL = 1e-6inparity_checks/bng_parity/net_roadrunner.py) failssilently: it returns
Noneand the caller turns that into an unscoredREFERENCE_FAILED row, so an export that drifts drops models out of parity rather
than failing anything loudly.
The companion change is in progress separately: export the same branch as a
piecewise(0.5*(delta+D), delta >= 0, 2*Km*S/(D-delta)). Two things make thatcheaper than it looks — the emitted MM law already contains
max(sFree, 0),and
_validate.py's_NONSMOOTHtuple listsMaxandPiecewisetogether, soL4 is already
inconclusiveon every MM model and a newpiecewisecannotregress it; and the writer already targets L3v2 for piecewise/min-max, since every
BNGL
if()exports as one.Separately, and genuinely pre-existing: the compiled RHS has no
sFree < 0clampwhere the interpreted path does, so on a negative-concentration probe the two
backends disagree (at
S = -1the compiled RHS givesdP/dt = -0.169where theinterpreted one gives 0) — and the emitted Jacobian, which does guard on
sFree > 0, contradicts the emitted RHS beside it in the same artifact. Nottouched here; it needs a decision about which behaviour is right rather than an
edit.