You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
generate_sens_rhs_c / generate_sens_from_model (python/bngsim/_codegen.py) return None on the first non-Elementary reaction, so any model with a Functional rate law gets no compiled sensitivity RHS and CVODES falls back to its internal difference quotient. CVodeSensInit1 takes one sens-RHS callback for all columns, so this is all-or-nothing per model.
Every if()-gated rate law is Functional, so the switch-time feature from #48 runs permanently on that fallback — but the problem is generic, not a #48 regression: beta alone is worse than all three switch times on the Lin2021 exemplar.
Across the BNGL-Models corpus:
27 / 65 .net files have >=1 Functional reaction -> whole model on the DQ path
including mek_isoform_erk_feedback_miller2026, where 1 Functional law out of 689 reactions puts all 689 on the difference-quotient path.
Why this is worth doing: a spike measured the mechanism
The DQ fallback causes a step-size collapse, not just expensive steps
Lin2021, plain solve vs a one-parameter (beta) sensitivity solve, day 100, 51 output points:
rtol
wall
n_steps
n_rhs
n_jac
err-test fails
plain
1e-8
0.72 ms
425
577
130
45
sens beta (DQ)
1e-8
5.64 ms
1 555
3 398
290
40
plain
1e-10
1.27 ms
871
1 065
189
58
sens beta (DQ)
1e-10
12 008 ms
5 559 838
12 852 513
1 761 155
1 472
The 9474x wall-clock ratio tracks a 6383x step-count ratio. Error-test failures stay low, i.e. the solver is succeeding at every minuscule step — the signature of an error estimate it can always meet and never grow.
Mechanism. CVODES' internal difference quotient perturbs the parameter by sqrt(rtol)*|p|, so the source term it returns is only ~sqrt(rtol) relatively accurate — 1e-5 at rtol 1e-10. Error control is asked for 1e-10 from a source term carrying 1e-5 of error, so it shrinks h chasing an accuracy the RHS cannot deliver. At rtol 1e-8 the DQ delivers ~1e-4 and the solver tolerates it. The cliff should therefore appear exactly when the requested tolerance passes below the DQ's own accuracy, which is what the table shows.
Controlled A/B: an exact source term removes the cliff
An all-Elementary synthetic SEIR (exponential growth from a single seed in a population of 2e7 over 100 days — the stiffness property blamed for the cliff) does get an analytic sens RHS. Forcing generate_sens_from_model / generate_sens_rhs_c to return None reproduces the Functional situation exactly (no bngsim_codegen_sens_rhs symbol => CVODES internal DQ) on identical dynamics:
rtol
wall
n_steps
plain
1e-8
0.07 ms
254
analytic sens RHS
1e-8
0.15 ms
272
forced DQ sens RHS
1e-8
0.15 ms
272
plain
1e-10
0.13 ms
492
analytic sens RHS
1e-10
0.29 ms
649
forced DQ sens RHS
1e-10
26.03 ms
76 935
DQ / analytic = 90.8x at rtol 1e-10, 1.01x at rtol 1e-8. Both gradients agree to rel 1.4e-8 — the DQ reaches the right answer, it just pays ~91x for it. With the analytic source term there is no cliff at all: 649 steps against the plain solve's 492.
And it flips the comparison against finite differences
Removing the cliff is necessary but not sufficient — forward sensitivity also has to beat central FD's 2N plain solves. Each analytic sens column is one linear variational solve (no Newton iteration), so it should cost ~1 plain-solve equivalent rather than 2. Measured on the same synthetic model:
rtol
N
analytic sens
per param
central FD (2N)
sens/FD
1e-8
1
0.123 ms
0.057 ms
0.131 ms
0.94x
1e-8
2
0.163 ms
0.049 ms
0.263 ms
0.62x
1e-8
3
0.215 ms
0.050 ms
0.394 ms
0.55x
1e-10
3
0.431 ms
0.106 ms
0.676 ms
0.64x
An analytic sens column costs 0.75–0.95 plain-solve equivalents against central FD's 2, so forward sensitivity wins from N=2 and the margin widens with N.
Lin2021 on the DQ path costs ~4 plain-solve equivalents per column today (2.6 ms/param against a 0.66 ms plain solve), which is why a tuned central FD is currently 2.3–5.1x cheaper per gradient there. Landing Lin2021 in the synthetic model's 0.75–1 band would be a ~4x per-gradient improvement at rtol 1e-8 and would turn a 2.3x loss to FD into a ~1.6–2x win.
What the spike does not establish: the magnitude on Lin2021 (synthetic cliff 91x vs Lin2021's 9474x — same mechanism, different severity), and whether the per-column constant transfers from a 4-species/3-reaction model to a 26-species/61-reaction one. The ratio argument is structural; the constant is not measured on a real Functional model.
Scope
The maths is simpler than the analytical Jacobian, not harder
For a Functional reaction, rate_r = func_r(obs, p, t) * prod(R) (.net, apply_species_factor=true) or rate_r = func_r(...) (SBML, per-species). prod(R) is species-only, so
There is no product-rule term and no chain rule through observable groups. The dfunc/dobs_k -> dobs_k/dx_j machinery in FunctionalJacobianData::ObservableTerm exists only to turn observable-derivatives into species columns; for df/dp the parameter is already the differentiation variable.
Most of the machinery already exists
piece
where
reuse
_inline_functions
_jacobian.py
as-is
_inline_derived_param_refs
_codegen.py
as-is — flattens sigma = t0+t_delta to primaries before diff
_exprtk_to_sympy / _build_local_dict
_jacobian.py
as-is
_is_emittable, sympy_to_c, resolve_symbol
_jacobian.py / _codegen.py
as-is — generate_jacobian_from_model's c_ref already maps both params (p[i]) and observables (obs[j])
_compute_derived_param_jacobian
_codegen.py
as-is — dp_d/dprimary; _emit_sens_rhs_body's derived_terms already threads it into the switch (iP)
_net_affected
generate_jacobian_from_model
as-is — (row i, coeff = stat*netstoich) is exactly the df/dp scatter
_emit_observable_lines / _emit_function_lines
_codegen.py
as-is — the obs[]/func[] recomputation the derivatives read
bngsim_output_sens_dfdp
generate_output_sens_from_model (#198)
closest analogue: already emits switch (iP) { dfdp_out[m] += ... } over per-function dfunc_m/dp_k with the #15 derived chain folded in, with an (iP, t, y, p, obs, func, out) signature. It is bngsim_dfdp one level up.
Measured on Lin2021: differentiating every Functional rate law w.r.t. every parameter it references takes 23 ms, yields 19 non-zero (law, param) partials, and emits valid C with zero emit failures.
The correctness hazard this introduces
sympy.diff of an if()/Piecewise w.r.t. a condition-only parameter returns a clean 0 (no Dirac delta, so _is_emittable never rejects it). Within a smooth segment that is correct, and #48's jump s+ = s- + (f- - f+)*dt*/dp supplies the whole contribution — so the switch jump stays the mechanism and needs no codegen change.
But that same 0 is a new way to be silently wrong. compute_switch_time_sens only recognises time / counter-clock thresholds. For a state-dependent condition (if(X >= thresh, ...) with thresh fitted) an analytic 0 with no jump is plainly wrong, whereas today's DQ probe at least perturbs the crossing and returns something. Switching to analytic df/dp must therefore be gated: decline the analytic sens RHS (per model — it is all-or-nothing) whenever a requested parameter occurs in a condition that is not a recognised clock threshold.
This is where the correctness risk concentrates and where the test effort goes.
Related: #198's _UNSUPPORTED_EXPR_CONSTRUCTS bans if(), comparisons and logicals outright ("fail loudly"). df/dp has to lift those three bans — every if()-gated law is Functional, which is the entire population — while keeping abs/min/max/floor/ceil/round banned. Note _is_emittable will not catch Min/Max, since both are in _SYMPY_FUNC_TO_EXPRTK; the construct gate has to do it.
What still has to bail
Michaelis–Menten reactions — out of scope for "Functional" but also on the DQ path today (the gate is != "elementary"). Closed-form and easy; ~1 day if bundled.
Table functions (tfun_*) — deliberately value-only, never differentiated.
rateOf — needs the live dx/dt buffer, unavailable in a sens-RHS call.
abs/min/max/floor/ceil/round — kinks with no jump machinery to compensate.
Hidden state — any free symbol that is not an observable, parameter or time (e.g. an SBML rate-rule target). Already caught by the existing free-symbol check.
State-dependent switch conditions — see the hazard above.
Variable-volume parameters (#171) — the Jacobian has volume_terms for d/dV_live; df/dV needs the analogue. Decline a fitted volume parameter initially.
assembly of the table above; bngsim_dfdp gains obs[]/func[], and _analyze_output_sens's pruning must add reaction-bound _rateLawN as roots
Functional bngsim_jac_vec
2–3
mechanical transform of generate_jacobian_from_model's balanced {...} groups into Jv_out[i] += coeff*val*v[j]; touches chunking/sharding and the dense/sparse split
mostly test design; the correctness risk lives here
MM df/dp + jac_vec
1
optional
validation: FD cross-checks on the 27 slow-path files, golden + #212/#48 regression
2–3
integration / CI / perf measurement
1
Low end 7 days if the Jacobian groups transform cleanly and no C++ layout change is needed; high end 15 if the sens-RHS user-data change ripples through the sharded-compile path and the SBML per-species path needs its own gating.
Two design points worth settling early:
bngsim_dfdp's current signature is (iP, t, y, p, dfdp_out) — it has no obs[]/func[], which the Functional derivatives read. Either extend the signature and recompute them the way generate_jacobian_from_model does, or pass them through CodegenSensUserData. Whichever is chosen, check whether it forces a _CODEGEN_VERSION bump and whether it ripples into compile_rhs's sharded path.
bngsim_jac_vec is Elementary-only. Emitting a Functional J*v mirrors the existing Jacobian groups; the alternative (reuse bngsim_codegen_jac/_jac_sparse and matvec) is cheaper to write but needs an n*n or CSC buffer live in the sens RHS.
Summary
generate_sens_rhs_c/generate_sens_from_model(python/bngsim/_codegen.py) returnNoneon the first non-Elementary reaction, so any model with a Functional rate law gets no compiled sensitivity RHS and CVODES falls back to its internal difference quotient.CVodeSensInit1takes one sens-RHS callback for all columns, so this is all-or-nothing per model.Every
if()-gated rate law is Functional, so the switch-time feature from #48 runs permanently on that fallback — but the problem is generic, not a #48 regression:betaalone is worse than all three switch times on the Lin2021 exemplar.Across the BNGL-Models corpus:
including
mek_isoform_erk_feedback_miller2026, where 1 Functional law out of 689 reactions puts all 689 on the difference-quotient path.Why this is worth doing: a spike measured the mechanism
The DQ fallback causes a step-size collapse, not just expensive steps
Lin2021, plain solve vs a one-parameter (
beta) sensitivity solve, day 100, 51 output points:beta(DQ)beta(DQ)The 9474x wall-clock ratio tracks a 6383x step-count ratio. Error-test failures stay low, i.e. the solver is succeeding at every minuscule step — the signature of an error estimate it can always meet and never grow.
Mechanism. CVODES' internal difference quotient perturbs the parameter by
sqrt(rtol)*|p|, so the source term it returns is only ~sqrt(rtol)relatively accurate — 1e-5 at rtol 1e-10. Error control is asked for 1e-10 from a source term carrying 1e-5 of error, so it shrinkshchasing an accuracy the RHS cannot deliver. At rtol 1e-8 the DQ delivers ~1e-4 and the solver tolerates it. The cliff should therefore appear exactly when the requested tolerance passes below the DQ's own accuracy, which is what the table shows.Controlled A/B: an exact source term removes the cliff
An all-Elementary synthetic SEIR (exponential growth from a single seed in a population of 2e7 over 100 days — the stiffness property blamed for the cliff) does get an analytic sens RHS. Forcing
generate_sens_from_model/generate_sens_rhs_cto returnNonereproduces the Functional situation exactly (nobngsim_codegen_sens_rhssymbol => CVODES internal DQ) on identical dynamics:DQ / analytic = 90.8x at rtol 1e-10, 1.01x at rtol 1e-8. Both gradients agree to rel 1.4e-8 — the DQ reaches the right answer, it just pays ~91x for it. With the analytic source term there is no cliff at all: 649 steps against the plain solve's 492.
And it flips the comparison against finite differences
Removing the cliff is necessary but not sufficient — forward sensitivity also has to beat central FD's
2Nplain solves. Each analytic sens column is one linear variational solve (no Newton iteration), so it should cost ~1 plain-solve equivalent rather than 2. Measured on the same synthetic model:An analytic sens column costs 0.75–0.95 plain-solve equivalents against central FD's 2, so forward sensitivity wins from N=2 and the margin widens with N.
Lin2021 on the DQ path costs ~4 plain-solve equivalents per column today (2.6 ms/param against a 0.66 ms plain solve), which is why a tuned central FD is currently 2.3–5.1x cheaper per gradient there. Landing Lin2021 in the synthetic model's 0.75–1 band would be a ~4x per-gradient improvement at rtol 1e-8 and would turn a 2.3x loss to FD into a ~1.6–2x win.
What the spike does not establish: the magnitude on Lin2021 (synthetic cliff 91x vs Lin2021's 9474x — same mechanism, different severity), and whether the per-column constant transfers from a 4-species/3-reaction model to a 26-species/61-reaction one. The ratio argument is structural; the constant is not measured on a real Functional model.
Scope
The maths is simpler than the analytical Jacobian, not harder
For a Functional reaction,
rate_r = func_r(obs, p, t) * prod(R)(.net,apply_species_factor=true) orrate_r = func_r(...)(SBML, per-species).prod(R)is species-only, soThere is no product-rule term and no chain rule through observable groups. The
dfunc/dobs_k -> dobs_k/dx_jmachinery inFunctionalJacobianData::ObservableTermexists only to turn observable-derivatives into species columns; fordf/dpthe parameter is already the differentiation variable.Most of the machinery already exists
_inline_functions_jacobian.py_inline_derived_param_refs_codegen.pysigma = t0+t_deltato primaries beforediff_exprtk_to_sympy/_build_local_dict_jacobian.py_is_emittable,sympy_to_c,resolve_symbol_jacobian.py/_codegen.pygenerate_jacobian_from_model'sc_refalready maps both params (p[i]) and observables (obs[j])_compute_derived_param_jacobian_codegen.pydp_d/dprimary;_emit_sens_rhs_body'sderived_termsalready threads it into theswitch (iP)_net_affectedgenerate_jacobian_from_model(row i, coeff = stat*netstoich)is exactly thedf/dpscatter_emit_observable_lines/_emit_function_lines_codegen.pyobs[]/func[]recomputation the derivatives readbngsim_output_sens_dfdpgenerate_output_sens_from_model(#198)switch (iP) { dfdp_out[m] += ... }over per-functiondfunc_m/dp_kwith the #15 derived chain folded in, with an(iP, t, y, p, obs, func, out)signature. It isbngsim_dfdpone level up.Measured on Lin2021: differentiating every Functional rate law w.r.t. every parameter it references takes 23 ms, yields 19 non-zero
(law, param)partials, and emits valid C with zero emit failures.The correctness hazard this introduces
sympy.diffof anif()/Piecewisew.r.t. a condition-only parameter returns a clean0(no Dirac delta, so_is_emittablenever rejects it). Within a smooth segment that is correct, and #48's jumps+ = s- + (f- - f+)*dt*/dpsupplies the whole contribution — so the switch jump stays the mechanism and needs no codegen change.But that same
0is a new way to be silently wrong.compute_switch_time_sensonly recognises time / counter-clock thresholds. For a state-dependent condition (if(X >= thresh, ...)withthreshfitted) an analytic0with no jump is plainly wrong, whereas today's DQ probe at least perturbs the crossing and returns something. Switching to analyticdf/dpmust therefore be gated: decline the analytic sens RHS (per model — it is all-or-nothing) whenever a requested parameter occurs in a condition that is not a recognised clock threshold.This is where the correctness risk concentrates and where the test effort goes.
Related: #198's
_UNSUPPORTED_EXPR_CONSTRUCTSbansif(), comparisons and logicals outright ("fail loudly").df/dphas to lift those three bans — everyif()-gated law is Functional, which is the entire population — while keepingabs/min/max/floor/ceil/roundbanned. Note_is_emittablewill not catchMin/Max, since both are in_SYMPY_FUNC_TO_EXPRTK; the construct gate has to do it.What still has to bail
!= "elementary"). Closed-form and easy; ~1 day if bundled.tfun_*) — deliberately value-only, never differentiated.rateOf— needs the livedx/dtbuffer, unavailable in a sens-RHS call.abs/min/max/floor/ceil/round— kinks with no jump machinery to compensate.time(e.g. an SBML rate-rule target). Already caught by the existing free-symbol check._inline_functionsguard.volume_termsford/dV_live;df/dVneeds the analogue. Decline a fitted volume parameter initially.df/dpmultiplies the SymPy work by N. Needs the same budget guard.Estimate: ~2 working weeks (7–15 days)
df/dpemitter (per-species + per-observable)bngsim_dfdpgainsobs[]/func[], and_analyze_output_sens's pruning must add reaction-bound_rateLawNas rootsbngsim_jac_vecgenerate_jacobian_from_model's balanced{...}groups intoJv_out[i] += coeff*val*v[j]; touches chunking/sharding and the dense/sparse splitdf/dp+jac_vecLow end 7 days if the Jacobian groups transform cleanly and no C++ layout change is needed; high end 15 if the sens-RHS user-data change ripples through the sharded-compile path and the SBML per-species path needs its own gating.
Two design points worth settling early:
bngsim_dfdp's current signature is(iP, t, y, p, dfdp_out)— it has noobs[]/func[], which the Functional derivatives read. Either extend the signature and recompute them the waygenerate_jacobian_from_modeldoes, or pass them throughCodegenSensUserData. Whichever is chosen, check whether it forces a_CODEGEN_VERSIONbump and whether it ripples intocompile_rhs's sharded path.bngsim_jac_vecis Elementary-only. Emitting a FunctionalJ*vmirrors the existing Jacobian groups; the alternative (reusebngsim_codegen_jac/_jac_sparseand matvec) is cheaper to write but needs ann*nor CSC buffer live in the sens RHS.Related
df/dpcoverage of theLambda/Plaws.