Repository navigation
Releases: lanl/bngsim
Release list
Data: nightly parity reference archive (reference-data-v3)
Reference-engine outputs for bngsim's nightly parity check (issue #805), recorded by https://github.com/lanl/bngsim/actions/runs/40899940910 at e425546.
Not a bngsim release. One asset per suite; each holds a README, entries.json (every entry's model, source and license) and, where any entry is not CC0, bngsim's NOTICE. The nightly replays from these once parity_checks/nightly/reference_archive.json pins them; parity_checks/nightly/README.md has the procedure.
Published by hand from that run's assets. The run's publish job was refused (HTTP 403) when it tried to create the tag at the recorded commit, so this tag points at main's head when it was published. The recorded commit is the one named above, and the manifest pins it.
Data: nightly parity reference archive (reference-data-v2)
Reference-engine outputs for bngsim's nightly parity check (issue #805), recorded by https://github.com/lanl/bngsim/actions/runs/40404704911 at 104078d.
Not a bngsim release. One asset per suite; each holds a README, entries.json (every entry's model, source and license) and, where any entry is not CC0, bngsim's NOTICE. The nightly replays from these once parity_checks/nightly/reference_archive.json pins them; parity_checks/nightly/README.md has the procedure.
Data: nightly parity reference archive (reference-data-v1)
Reference-engine outputs for bngsim's nightly parity check (issue #805), recorded by https://github.com/lanl/bngsim/actions/runs/40125305877 at 2b5a602.
Not a bngsim release. One asset per suite; each holds a README, entries.json (every entry's model, source and license) and, where any entry is not CC0, bngsim's NOTICE. The nightly replays from these once parity_checks/nightly/reference_archive.json pins them; parity_checks/nightly/README.md has the procedure.
Data: BioModels SBML for rr_parity (331 models, snapshot 2026-07-03)
Data, not a bngsim release. No package, nothing on PyPI.
331 SBML files that only BioModels serves: the biomodels_dir jobs of parity_checks/rr_parity/ode_jobs.json. Each is the version pinned by benchmarks/suites/biomodels/biomodels_provenance.json (snapshot 2026-07-03), with BioModels' bytes unmodified.
Why it is here. BioModels sits behind Amazon CloudFront, which refuses every request from a GitHub-hosted runner with its own "Request blocked" page (#807). The nightly parity workflow reads this copy instead, so it can run all 1,323 BioModels models rather than 1,006.
License. BioModels dedicates its whole dataset to the public domain under CC0 1.0: https://www.biomodels.org/termsofuse
Checking it. biomodels-sbml-2026-07-03.tar.gz (11.2 MB), sha256 09e4337984bb03737f6120380d8b4cb40b7d23f3206591bbab2bfcd641a1c407.
parity_checks/rr_parity/fetch_biomodels_mirror.py downloads it, checks that sha256, then checks every file against its record in the provenance manifest. --build rebuilds the identical archive from a corpus fetched with benchmarks/suites/biomodels/fetch.py.
bngsim 0.16.0
pip install bngsim==0.16.0
Added
-
Per-reaction firing counts and propensity integrals from an exact SSA
run (issue #616).Simulator(model, method="ssa", reaction_stats=True)
records, at every output time,N_r(t)and∫₀ᵗ |a_r| dsfor each
reaction — the two accumulators the likelihood-ratio (Girsanov) parameter
gradient of an ensemble is built from — onResult.reaction_firing_counts,
Result.reaction_propensity_integralsandResult.reaction_labels
((n_times, n_reactions); empty unless asked for). The blocks survive
run_replicates(sequential and parallel; stacked to
(n_sims, n_times, n_reactions)undersqueeze=True),save/loadand
to_dataset. The newbngsim.girsanovmodule turns them into scores for
named mass-action rate constants (scores, per parameter or perlog₁₀)
and into the ensemble gradient with its standard error (gradient, the
sample covariance of observable and score). Off by default, and enabling
it leaves every trajectory byte-identical: the integral is banked at the
moment a propensity is rewritten, so the incremental loop pays
O(affected) per step for it, the same as for the propensities. Exact SSA
only — PSA, ODE and network-free refuse the flag — andscoresrefuses a
model with a functional or Michaelis–Menten rate law, whose score is not a
function of the two accumulators. -
Model.analytical_jacobian_status: why a model runs on the
finite-difference Jacobian, in words (issue #506)."complete",
"pending"(functional rate laws not derived yet), or"declined: <reason>"
— the rate law and what stopped its derivation, the derivation budget, the
C++ attach gate, or the environment switch — so a harness can record the
reason instead of only the booleananalytical_jacobian_complete. Carried
byclone(). -
Result.state/Result.state_names: the full integrator state
trajectory (issue #508).Result.speciesis the reported block. An SBML
event that assigns a parameter or a compartment promotes its target to a
state entry the species projection hides (GH #71), and a species column in
an event-resized or rate-ruled compartment, or one an assignment rule
targets, is remapped to its reported value after the solve (GH #85, #131) —
so a row ofspeciesis not a stateModel.rhs/Model.jacobianaccept:
too short, or not the point the integrator was at.stateis every entry
as the integrator held it,(n_times, n_state)inModel.species_names
order, andstate_namesnames its columns; on a.netmodel, and on an
SBML model with nothing promoted or remapped, it is the same array as
species. Solve results only: aResultloaded from disk or stacked from
a batch raisesValueErrorfor it. -
The model's RHS, Jacobian, stoichiometry matrix and propensity vector are
public NumPy evaluators, and a steady-state result carries the spectrum its
stability verdict was read off (issue #523).Model.rhs(y, t),
Model.jacobian(y, t, sparse=),Model.propensities(y, t)and
Model.stoichiometry_matrix(sparse=)evaluate at any state the caller
passes, under the live parameters, without touching the stored
concentrations. Each is the evaluator the corresponding engine runs, not a
re-derivation:rhsis the CVODE callback's interpreted RHS with its
observable-and-function refresh run aty;jacobiantakes the closed form
when it covers every reaction and the steady-state solver's own difference
quotient otherwise — the same rule, lifted intobngsim/fd_jacobian.hppso
the two cannot diverge — and reports which through.source
("analytical"/"finite-difference", the spellings
solver_jacobian_sourcealready used); a partial analytical Jacobian is
never returned as if whole.propensitiesis the SSA loop's propensity pass,
refresh included, in the SSA volume convention (falling factorial for a
repeated reactant, times the compartment volume), and says so.
stoichiometry_matrixis the net-coefficient matrix conservation-law
detection row-reduces, 0-based, with a zero row for a$-fixed species. The
dense Jacobian is abngsim.JacobianMatrix(anndarraywithsource);
sparse=Trueon either matrix returns a scipy CSC array over the model's
structural pattern. The C++ names are bound one-to-one on the core
(compute_derivs,fill_dense_analytical_jacobian,fill_dense_fd_jacobian,
fill_sparse_analytical_jacobian,compute_propensity,
compute_propensities,stoichiometry,jacobian_sparsity,
species_is_fixed).SteadyStateResult.eigenvaluesis the spectrum of the Jacobian restricted to
the species the Newton polish solved for — the matrix the issue #78
certificate already took and discarded after reading one bit — sorted by
descending real part with a conjugate pair adjacent,+imagfirst. It is
filled whenever the certificate computed a spectrum and empty when it did
not: an integration result, or a root it declined on above 512 unknowns,
consistent with"undetermined"there. Together these are the provider the
continuation (#501), multistart (#524) and generator (#504) work assumes.
New user-guide page: Model evaluators.
Changed
-
copy.copyon aModelor aSimulatornow refuses instead of handing
back an alias (issue #643).copy.copy(model)returned aModelsharing
the original's engine handle, which is textbook shallow-copy behaviour —
copy.copyis documented to insert references to the objects found in the
original. What made it a trap here is that all of aModel's state lives
behind that one handle, so there was nothing left for the copy to own: it was
an alias with a differentid(). Aset_paramorset_concentrationthrough
the "copy" landed on the original, which then integrated to a different answer
with nothing raised. ASimulatorshares itsModeloutright and aliased by
the same route. Neither of the two things a caller reaches for next corrected
it:copy.deepcopyraises (it cannot pickle the handle), andclone(), which
does give an independent model, is invisible from the copy path.Both now raise a
TypeErrornaming what the copy would have shared and the
remedy —clone()for aModel,Simulator(model.clone(), method=...)for a
Simulator. Breaking, and deliberately so: it removes an operation that
ran before, which is the point, because it ran, looked right, and quietly made
the original wrong. Returningclone()from__copy__was considered and
rejected — it would trade a loud failure for a silently different one for any
caller who copies and then mutates.copy.deepcopydoes not route through__copy__— it falls to__reduce_ex__,
so it still reports the issue #636 pickle refusal. That message now ends with
the in-process half for aSimulatortoo ("Within this process, build a second
Simulator overmodel.clone()"), whichModel's has carried since #636 and
which is the only advice that fits adeepcopycaller, who is not crossing a
process boundary at all. It is a separate sentence, guarded by "within this
process", so it cannot be read as a way to make aSimulatorpicklable.Resultkeeps its shallow copy. Its API is read-only — no setter, no mutating
method — so no call on the copy can write into the original; a test pins that
premise, and fails if a setter is ever added. Its arrays are shared, so an
in-placecopy.copy(result).species[0] = ...does reach the original — but so
does the same write throughresultitself, since every access returns the
stored array rather than a fresh one. That is documented onResultrather
than fixed, being numpy's aliasing and not the copy's. -
Pickling a
Result,ModelorSimulatornow says what to do instead of
naming an internal C++ type (issue #636). Each holds a handle into the
compiled extension, so none of them can cross a process boundary — a design
boundary, not a defect. What a caller got was
TypeError: cannot pickle 'bngsim._bngsim_core.ResultCore' object, which
names a type they have never seen, at the moment they are parallelising a fit
across a process pool and need to know that the way through is to convert
first. The refusal now names itself, the reason, and the conversions that
carry what a worker actually needs:as_roadrunner(),to_xarray(), the
plain.time/.species/.observablesarrays, orsave(path)here and
Result.load(path)on the other side; for aModel, rebuilding it there
from the same source, withclone()for an independent copy in this process.
bngsim's own parallelism is threads, so nothing in the library ever reached
this message — a consumer reaches it on the first return value.copy.deepcopygets the same sentence, having previously failed with the same
internal type name.copy.copywas deliberately left as it was: it falls back
to__reduce_ex__, so the refusal would otherwise have taken a shallow copy
with it as a side effect of a change about an error message. What a shallow
copy of these three should mean was then settled on its own terms — see
the issue #643 entry above. Every remedy the messages name is checked by a test to
exist and to survive a round trip, because an error message that recommends a
method is a claim about the API. -
SIR_v4is re-sourced from wshlavacek/BNGL-Models after its model fix, so
the corpus model keeps its analytical Jacobian and analytic sensitivity RHS
(issue #543). Its four year-selectionif()chains fell back to 0 after its
last modelled year, so its rate law divided by zero past day 1461
(wshlavacek/BNGL-Models#55 ends them with the 2024 values), and each season
opened ont>=t_start(), the one instant whe...
bngsim 0.15.1
pip install bngsim==0.15.1
Changed
-
Re-vendored RuleMonkey 3.10.0 (
3d97f66) → 3.10.1 (87628b3). A patch
release of two performance fixes. Nothing a model does undermethod="rm"
changes: both upstream entries record all 164 corpus, feature-coverage and
BNG2-oracle models producing byte-identical output, RuleMonkey'sinclude/is
byte-identical to 3.10.0, and no Tier-0 refusal, warning or diagnostic is added
or removed. Only cost changes, and it changes by orders of magnitude.A rate law that reads a
Speciesobservable no longer full-walks the pool
after every SSA event. ASpeciesobservable is tracked incrementally, but
the tracked value only settled when its dirty complexes were flushed, and that
flush fired at sample time — far too late for a rate law reading the value on
the very next propensity recompute. So RuleMonkey excluded rate-dependent
Speciesobservables from the tracker outright and walked every complex holding
a molecule of the pattern's seed type, from scratch, after every event. That
made such a model quadratic in its own seed population; the flush now also runs
after each event, for the observables a rate law actually reads.seed before after 10 000 88.9 us/event 5.3 us/event 100 000 559.0 us/event 3.3 us/event 300 000 did not finish 3.6 us/event The charge did not need the observable to move: a rate over a
Species
observable of an inert pool no rule touches paid a flat ~650 us/event for a
value that could not have changed.The same fix then had to reach multi-pattern
Speciesobservables, which
the first gate admitted only one pattern at a time —Specieshas taken any
number of patterns all along, so those kept falling through to the full walk
(3 502 us/event at 10 000 seed, against 14.3 after). Lifting it was a semantics
change rather than a gate change: the walk applies each pattern's quantity
relation to that pattern's own complex-wide count, so a complex matched by two
patterns counts twice, and the tracker's one-tally-per-complex tables had to be
split per pattern first. An n-pattern observable now holds n times as much;
the peak-RSS sweep across all 164 models moves by less than its own
run-to-run spread.Unlike the 3.9.0 → 3.10.0 refresh, this one needed no two-repo ordering.
RuleMonkey's standalonethird_party/bngsim_exprcopy already matched this
tree on all fourEXPRTK_SYNC_FILESpaths, so the guard passed as-is and the
vendored surface moved by four files:CHANGELOG.md,CMakeLists.txt,
cpp/rulemonkey/engine.cppanddocs/model_semantics.md. -
MODEL2002070001'sINVALID_REFERENCEpremise is relaxed: a reference that
answersNaNis not a comparison basis, whatever bngsim did (#485). The
0.15.0 sweep flagged the entry stale —natural=DIFF, rr_finite=False— and it
was half right. The entry claimed both engines were broken; bngsim's half of
that stopped being true in 0.14.0, when the #353 unit-volume substitution for a
non-finite compartment size let it integrate the model. RoadRunner's half is
unchanged. So the natural verdict resurfaced as aDIFFwithmax_rel_err
inf, 606/707 cells failing — RoadRunner'sNaNscored against bngsim as a
divergence, the one thing it is not.The model is well posed and the
NaNis not in its dynamics. Both compartments
declaresize="NaN", but every species ishasOnlySubstanceUnits="true", the
dynamics are 7 rate rules with no reaction, and no<math>block in the file
namesmucosaorlumen— nothing reads a size, so the IVP is well posed in
amounts and bngsim's substitution warning describes a value nothing reads.
RoadRunner carries species as concentrations, sox1..x6— the six species in
those compartments — areNaNfromt = 0, whilex7, the one rate-rule
variable that is a parameter rather than a species, stays finite and agrees
with bngsim cell for cell. TheNaNreaches RoadRunner's state through the
compartment, not through the dynamics.At the sweep tolerance (rtol 1e-9 / atol 1e-12,
t= 0..100, 101 points):
bngsim 707/707 cells finite; RoadRunner 2.9.2 runs without raising and returns
101/707; AMICI 1.0.1 confirms bngsim's trajectory independently, 0/707 cells
failing atmax_rel_err0.0. Two engines produce a trajectory here and the
third producesNaN, which is all the override needs to claim: RoadRunner's
output is not a usable comparison basis._apply_invalid_referencenow keys the premise on the reference alone — RR ran
and emitted non-finite output — instead of also requiring that bngsim failed.
What that requirement was really doing was guarding against burying a bngsim
defect, and a model bngsim can now integrate needs a guard that still works:
_reference_nonfinite_coversre-runs the same verdict over just the columns
the reference kept finite, and the override holds on aDIFFonly if that
passes. A real bngsim divergence beside theNaNleaves a failing cell in a
finite reference column, and the row stays aDIFF. Restricting the columns
can only tighten the re-run (scaleis the peak over the retained block), so
the claim it licenses is never a loosening.Two shapes the relaxation deliberately does not cover.
rr_finite is None
— RoadRunner raised, or a segfaulted child left no per-engine status — stays
stale on anything but a naturalBAD_TEST: "the reference raised" and "we
never learned" are indistinguishable there, andREFERENCE_FAILEDis already
the right bucket for the first. A naturalPASSstays stale too: if the
compared columns agreed, the reference was usable for the comparison whatever
it did elsewhere.The row lands on
BAD_TEST, notPASS.PASSis the #482/#483 treatment for
a divergence attributable to the reference engine, and it would score
RoadRunner as having validated a model it answeredNaNon.BAD_TESTsays
what happened: the model's own SBML put aNaNin the reference's state, and
no comparison exists. Both are non-scoring, so neither costs bngsim a model.Suite-side only — no bngsim behavior changes.
overrides.pyrewrites the entry
(now citing #485, the AMICI confirmation, and thex7control),
ode_jobs.jsonre-bakes the one job record, andtest_outcome_classify.py
pins the relaxed premise and the new guard. This was the lastDIFFin the ODE
sweep: 1237PASS/ 27REFERENCE_FAILED/ 54BAD_TEST/ 5TIMEOUTover
1323 jobs, noDIFFand noEXCEPTIONrow, and no override flagged stale. -
BIOMD0000000627's rr_parity divergence is allow-listed as a
KNOWN_ARTIFACT:
RoadRunner steps over a smooth pre-stimulus ramp, and bngsim is the correct
engine (#482). Reported as a 0.13.0 regression on the premise that the
pre-switch trajectory is a steady state, so every sample beforet = 200must
sit on a flat baseline. It is not, and they must not.Jolivet2015 gates its stimulus on an assignment rule,
is_stimulated = piecewise(0, (time<=200) or (time>=t_0+t_end), 1), but the
blood-flow multiplier alongside it is ungated:f_CBF_dynis a logistic
centred att_0 + t_1 - 3= 199, so perfusion (F_in = F_0*f_CBF_dyn) runs
1.0000004 → 1.4157864 overt= 196..200 — the response leads the stimulus by
one second, by construction.species_24(CO2) is washed out along that ramp,
so the correct trajectory is already falling well before the switch.bngsim resolves the ramp because the #305 crossing stop lands the step on
t = 200, which pulls the step size down on the approach. The ramp itself is
smooth: it brackets no #72 root, and the model has nofloor/ceiling/
modulo, so_periodic_disc_max_stepisNoneand #274 never enters. Remove
the #305 stop and bngsim reproduces RoadRunner's answer bit for bit — which is
exactly what bngsim ≤ 0.12.2 did, and why the fix reads as a regression.RoadRunner at the sweep grid steps clean over the ramp and reports the flat
baseline 2.2084924 throught = 200(worstspecies_24att= 199.6:
bngsim 1.9434505 vs RR 2.2084924, reld 0.136). Its answer gets worse as tol
tightens — at rtol/atol 1e-11/1e-14 and 1e-12/1e-16 it stays flat through
t= 200.4 as well, then missing part of the stimulus response too. Tol-stable
wrongness is the signature of a feature never sampled, and a denser output grid
cannot help, becauseCV_NORMALinterpolates output points.TOL_OVERRIDESis
therefore not the remedy.Three independent converged references agree with bngsim to 7 digits at
t= 197.6/198.8/199.6/200.0/200.4
(2.2081597/2.1457629/1.9434505/1.9173758/1.9130018): RoadRunner with
maximum_time_step0.05; RoadRunner withmaximum_time_step0.01, agreeing
with the first to 7 digits and so converged; and RoadRunner with no step
bound at all, simply restarted att = 190so its own step control has to
resolve the window.Suite-side only — no bngsim behavior changes.
parity_checks/rr_parity/ overrides.pygains the entry andode_jobs.jsonre-bakes the one job record. -
Three
rr_parityoverride keys that could never match are retired, and a
test now says so (#482 follow-up).MODEL0913003363:ode(TOL_OVERRIDES),
MODEL1006230083:odeandMODEL6963432821:ode(bothNO_ORACLE_ADJUDICATED)
name models the ODE manifest does not build: all three aredrop,no_modelin
the stage-1 corpus filter, their fetched files zero bytes (sha256 = the empty
string's), one of 305 corpus entries in that state. They have been stale since
the initial public release.A stale key is silent rot. It reads as a live disposition while covering
nothing, and it is the shape a typo'd or renamedmodel_idtakes — an override
...
bngsim 0.15.0
pip install bngsim==0.15.0
Added
-
A model that calls
mratio()in a rate law now gets an analytic forward
sensitivity instead of CVODES' difference quotient (issue #457). The
differentiation layer had never heard ofmratio, so any derivative through
it came back unevaluated and the whole model fell back to the difference
quotient. That answer is correct but slower, and the derivative it was falling
back from is available in closed form.mratio(a,b,z)isM(a+1,b+1,z)overM(a,b,z), and Kummer's identity is
dM/dz = (a/b)*M(a+1,b+1,z). Through the quotient rule, writingRfor
mratio,dR/dz = R(a,b,z) * [ (a+1)/(b+1)*R(a+1,b+1,z) - (a/b)*R(a,b,z) ]so the derivative in the third argument is two more
mratiocalls. No new
special function and no new numerics. This is the derivative worth having:
BNG buildsaandbfrom molecule counts and puts the rate constant inz,
asz = -1/Keq, so fitting a rate constant goes through exactly this. The
first two arguments have no comparable closed form and get none. A model
differentiating through one of them declines and keeps the difference
quotient, as it did before.The second call is the awkward part. Issue #453 gave
mratioa region it
trusts and made it refuse outside it, and(a+1, b+1, z)is not automatically
inside: 97.6% of the argumentsmratioanswers for have the shifted call
answered too, and the whole of the rest hasaabove -1, wherea+1turns
positive. Every model BNG generates hasaat or below -1. Refusing the rest
would turn a model that runs today into one that fails, since the compiled
path has no retry, so the emitted helper falls back to a second exact
expression for the same derivative. Kummer's equation
z*M'' + (b-z)*M' - a*M = 0gives the contiguous relation(a+1)/(b+1)*R(a+1,b+1,z)*R(a,b,z) = ( b - (b-z)*R(a,b,z) ) / zso the whole derivative follows from the one call the value already makes.
That subtraction cancels, which is why it is the second choice and not the
first: against a 60 digit reference over 1293 arguments the shifted-call form
is right to 9e-16 at the median and this one only to 5e-13, and for a small
|z|it loses everything. A small|z|is wheremratiotrusts the shifted
call unconditionally, so the fallback only ever runs at a large|z|, where
its worst measured error is 1.7e-8.The interpreted engine has
mratiobut nothing for its derivative, so the
interpreted Jacobian declines it and uses finite differences, which is what it
did before. Only the generated C has the helper. -
mratio()answers for 40% more of the arguments it used to refuse (issue
#456). Issue #453 mademratio(a, b, z)refuse the arguments its continued
fraction cannot be trusted with, which removed a class of silently wrong
answers. The cost was that it also refused arguments the fraction would have
got right, since in the uncertain region there is no way to tell which ones
those are. This narrows that region with a second method that can vouch for
its own answer.For a large
|z|the ratio has an asymptotic expansion in which the Gamma
factors cancel between numerator and denominator. That cancellation is the
point: the individual Kummer functions overflow a double long before their
ratio does, which is the whole reasonmratioexists.z to -infinity: R(a,b,z) = (b/(-z)) * 2F0(a+1, a-b+1; ; -1/z) / 2F0(a, a-b+1; ; -1/z) z to +infinity: R(a,b,z) = (b/a) * 2F0(b-a, -a; ; 1/z) / 2F0(b-a, 1-a; ; 1/z)2F0is a divergent asymptotic series, so each is summed to its smallest
term, which is where such a series comes closest to the function it stands
for, and that term estimates how close it got. Certifying its own accuracy is
what makes the route safe to add at all: it is consulted only for arguments
that were going to be refused, and only its own estimate lets it answer, so it
cannot change a valuemratioalready returns and cannot turn a refusal into
a wrong number. Where the estimate is not small enough the refusal stands.Two things it gets wrong if they are not guarded, both found by breaking it.
An expansion whose leading coefficient is1/Gamma(b-a)has no leading term
at all whenb - ais a non-positive integer, so what is left is the
exponentially small piece the expansion drops; unguarded it returned 5e-06 for
M(a,a,z)over itself, which is exactly 1. And a series that stopped early
because a term hit exactly zero has demonstrated no decay, so its smallest
term says nothing; unguarded it returned 14.33 where the ratio is 0.9998.
Neither needs a test of its own in the end.lgammais+infat exactly the
first set of arguments, so estimating the dropped branch in logs refuses them
by arithmetic, and taking the estimate to be the last term actually added
rather than the zero that ended the series covers the second.Measured over 5955 argument triples against a 40 to 60 digit reference, the
refused fraction of the grid falls from 46.4% to 28.5% and the worst error
among the newly returned values is 2.7e-11. The estimate itself is honest to
about a factor of ten, so the 1e-10 it is held to is worth roughly 1e-9 in the
worst case.As with the fraction,
src/expression.cppis the single source of truth and
the generated C carries a copy held to it. The two agree bit for bit at every
one of the 5955 triples. -
A dosing schedule that went through libSBML now gets the same gradient as one
written by hand (issue #465). libSBML does not emitrem(a, b). It expands it
into a sign test over two remainders,if(sign(a) != sign(b), a - b*ceil(a/b), a - b*floor(a/b))so a model writing
rem(time(), P) >= d— the same repeating schedule issue #436
compensated — arrived with the schedule hidden behind anif()inside the
condition, and was refused for having two step functions where the recogniser
wanted one. Five corpus models write it. The gradient a model got therefore
depended on which tool had written its SBML, which is not a distinction anyone
fitting it can act on.The schedule is unchanged; what is new is getting to it. An
if()inside a
condition arrives as aPiecewise, and it is collapsed to the branch the run
takes before the step functions are counted. Which branch that is cannot be read
off the text: for a positivePthefloorremainder runs over[0, P)and the
ceilone over(-P, 0], so the two differ byPeverywhere except at the
multiples of it. So the guard is resolved against the run's own clock domain and
the model's parameter point —sign(clock)is 1 for the whole of a run, what is
left must be clock-free, and it must evaluate at the parameter point. A negative
period selects the other branch and is read just as well.The route not taken is the interesting part. The obvious fix is to propose
both branches and keep whichever_schedule_matches_residualaccepts, since that
check exists to catch a schedule the model does not follow. It is unsafe, and the
failure is silent. Theceilbranch of a positive-period schedule reads as a
negative period, so the check's probe points land at negative clock values —
outside any run window — and there the guard really does select that branch and
the residual really does hold one sign for the whole period. It is accepted as a
schedule that never turns over, and the model is admitted with every one of its
real crossings uncompensated: exactly the quiet failure that check was built to
prevent. That is pinned as a test rather than left as a comment.A guard that still reads the clock after the substitution is declined, because it
changes which branch is live partway through the run and no single period, offset
and duty describes the window — MODEL1006230027's shape, which stays refused
along with MODEL1708310001's remainder of a remainder and Sturrock2015's
sinusoid.One thing had to move besides the recogniser.
_iter_condition_atomsdescends
into anif()'s condition as readily as into its branches — rightly, since a
nestedif()in a branch is a real discontinuity — so the guard
sign(time()) != sign(P)also arrived as an atom in its own right, was neither a
clock threshold nor a comparison over state, and refused the model even with the
schedule around it fully compensated. It is not a crossing:sign(clock)is 1
for the whole of a run, so it picks its branch before the first step and holds
it. That is the same groundcondition_cannot_crossalready applies to a
comparison over run-constants, reached by reading a value rather than a name —
which is why it could not go in that function, documented as structural and
never numeric, and is asked fromclock_crossing_compensatedinstead, which is
value-dependent already and is whatswitch_gate_cache_digestcarries. It is
narrow on purpose: only an atom that reads the clock and stops reading it once
sign(clock)is resolved is claimed, sotime() >= sigmais left to the
recognisers exactly as before.The gradient is checked against a central difference of two trajectories on every
column, through the same h-ladder oracle issue #436 uses, and the same model
written the two ways places identical edges with identical∂t*/∂p. Forcing the
collapse to the wrong branch fails seven of the new tests, thePandd
columns by an order-one fraction. -
A rate law that switches on a repeating schedule now has a forward-sensitivity
gradient (issue #436).if(time() - 24*floor(time()/24) >= 7, on, off)is "on
for the last 17 hours of every 24 hour day", and with a start ...
bngsim 0.14.0
pip install bngsim==0.14.0
Added
-
A rate-law switch condition that is a single power of the clock is now
compensated, so its forward-sensitivity run is no longer refused (issue #418).
if(time()*time() >= thresh, ...)— the shape issue #414 refused, since neither
issue #48's affine solver nor issue #150's state root brackets it — has a
crossing in closed form:c·clock^nis strictly monotonic onclock ≥ 0, so it
crosses exactly once, atclock = (thresh/c)^(1/n)._clock_monomial_threshold
solves that (time = sqrt(thresh)for the quadratic), and because the result is
the clock value at the crossing — the same(clock, threshold_expr)contract
_clock_affine_threshold(issue #355) already returns — the whole issue #48
machinery jumps it unchanged:∂t*/∂thresh = 1/(2·sqrt(thresh)), the crossing
registered as a stop time, the in-branch∂f/∂pa clean Piecewise zero. The
detector and the issue #68 gate share the one recognizer, so a model that was
declined for this shape is now admitted on both. This is the first step of the
machinery half of issue #414; a clock threshold that is not a bare power
((time-5)^2, two crossings;time^2 + time, mixed) has no single crossing to
name from the text alone and stays refused, and the difference-quotient
reproducer that #415 exercises under the MIR JIT now runs the analytic path with
its self-multiply in the sensitivity RHS as well as the state RHS. -
A codegen artifact says which codegen key built it, so a cache sweep can tell
live from orphaned (issue #363). Artifacts are now named
rhs_<key>_<hash><suffix>— e.g.rhs_28+317a5b34d5dc9959_9e1f…— where<key>
is_CODEGEN_CACHE_KEY, the_CODEGEN_VERSIONconstant plus a digest of the
emitters' own source (#51). The key is still mixed into<hash>, so nothing
about invalidation changes; carrying it beside the hash is what makes the dead
corpus countable.That matters because #51's key is deliberately conservative: any edit to
_codegen.py/_jacobian.py/_saturable_jacobian.py/_switch_sensitivity.py
— a comment included — orphans every artifact on the machine at once. So on a
machine that tracks bngsim development, "everything from before my last emitter
edit is garbage" is the common case, and until now it was unexpressible: two
opaque hex strings, with time as the only signal a filename carried.bngsim-cache inforeports live and orphaned as two numbers, plus a
per-key table — the audit of a shared or pre-warmed artifact directory, which
says which bngsim's artifacts are in it and how much each holds.bngsim-cache prune --orphanedsweeps exactly the artifacts this install can
never load again. Much better targeted than--older-than, which keeps orphans
that happen to be recent and evicts live artifacts that are merely idle. The
--min-agefloor still applies: a fresh orphan is a sibling process's compile
under its own key, about to bedlopened.--keep-key KEY(repeatable) spares another install's artifacts, because a venv
per project is ordinary and each has its own key — "orphaned" from one venv's
point of view is live from another's, and sweeping on that would make a shared
cache thrash.prune_codegen_cache(orphaned=True, keep_keys=[…])is the API
form; passingkeep_keyswithoutorphanedraises rather than reading as a
protection the age and size bounds do not honor.CacheEntry.codegen_key,CacheInfo.live/.orphaned/.by_key, and
bngsim.cache.artifact_key()expose the same thing in process.
This is a one-time full invalidation. Every artifact already on disk is under
the old scheme and unreachable under the new one, so every model recompiles once.
infocounts those pre-#363 names as orphaned (nothing will ever look one up
again) and lists them under-;prune --orphaned --keep-key -spares them, for
a directory shared with an install too old to write a key. No_CODEGEN_VERSION
bump: this edits_codegen.py, so #51's source digest invalidates every cache on
every machine anyway — which is the argument for landing the rename together with
it, since the cost is paid once either way. -
The codegen artifact cache is inspectable and prunable:
bngsim-cache
(issue #205).~/.cache/bngsim/codegengrew without bound and there was no
supported way to look at it — 2.0 GB across 14,377 entries after six weeks of
ordinary work on one developer machine, withrm -rfon a path you had to know
by heart as the only remedy. New console scriptbngsim-cache(also
python -m bngsim.cache, which needs no reinstall) with four verbs:info— path, entry count, total size, build/last-used dates, and a
breakdown by artifact kind (model RHS, SSA propensity, thesrc_fallback
key of #174, plus the leaked partials below).clean— remove only the debris of interrupted compiles:bngsim_shard_*
scratch directories, the strayrhs_<key>_<hash>.<pid>_<n>.cbeside them, and
temp/sidecar libraries. Nothing else cleans these up, and no compiled
artifact is touched, so no cache hit is lost.prune --older-than 30d/--max-size 2G— evict least-recently-used
artifacts to fit an age and/or size bound. The partial sweep runs first, so
the size cap is a bound on the whole directory.clear— everything, behind a confirmation prompt (--yesfor scripts;
required, not assumed, when stdin is not a terminal).
--dry-runon every mutating verb,--jsononinfo, and-C/--cache-dirto
point any of them at a directory other than the configured cache — e.g. to
audit a pre-warmed artifact directory from a login node.
bngsim.codegen_cache_info(),clean_codegen_cache(),
prune_codegen_cache()andclear_codegen_cache()are the same four in
process, for a notebook or a fitting harness.Nothing prunes automatically, deliberately. No size cap at
compile_rhs
time, no sweep on import or onSimulatorconstruction. A library that deletes
files as a side effect of being used is a surprise, and the failure mode —
evicting the artifact another process is about todlopen— is the exact class
of bug the cache exists to avoid.Two safety properties hold across every verb. Only files bngsim wrote are
ever removed:BNGSIM_CODEGEN_CACHE_DIRis a user-supplied path that people
point at shared scratch, so anything unrecognized is classifiedforeign,
reported, and left alone — byclearas much as byclean. And nothing
recently touched is removed: a compile in flight writes its scratch files
into this very directory, so every verb holds off on entries used or written
within--min-age(default1h, over the 600 s defaultBNGSIM_CODEGEN_TIMEOUT) and
on POSIX additionally holds a partial whose compile is still running.LRU order is
max(atime, mtime), which was measured rather than assumed: a
plainread()on macOS APFS leavesatimeuntouched whiledlopen— the only
way this cache is ever used — advances it, and anoatimemount never moves it
at all. Taking the newer of the two gives true recency wherever the filesystem
records it and degrades to build order where it does not, andinforeports
which one you are getting.Not addressed here, and filed rather than guessed at: an entry carried no
record of the codegen key that built it, so nothing could report how much of the
cache was orphaned (#363, above, which put the key in the filename); and MSVC
leaked a.lib/.exppair per successful Windows compile (#362, whose fix rode
along with an invalidation this release was paying anyway —cleancollects any
debris left from before it). -
BNGL models load, behind a
bnglextra (issue #162).
Model.from_bngl("m.bngl")andModel.load("m.bngl")now work;
_LOAD_DISPATCHgained.bngland the "expand it yourself first" refusal is
gone. bngsim still has no BNGL parser and does not want one — BNGL describes
rules, and turning them into a network isBNG2.pl generate_network's job —
so the loader shells out and reads the emitted.netthrough
Model.from_net.pip install 'bngsim[bngl]'supplies BNG2.pl via
PyBioNetGen;$BNG2_PL/$BNGPATH/ aBNG2.plonPATHall outrank it,
andperlhas to be there too (so BNGL loading reports itself unavailable on
stock Windows rather than failing at load time).The file's experiment is not executed — and only the experiment is
stripped. A.bnglin the wild ends insimulate({...})or
parameter_scan({...}), and BNG2.pl runs whatever it is handed, so loading a
model would have meant running the author's whole experiment to obtain a
networkgenerate_networkalone produces in seconds. But dropping every
action is the opposite mistake, and the first cut here made it: an action-scope
setOption("NumberPerQuantityUnit",6.0221e23)above the model block
configures the generation, and without it BNG2.pl emitted the same topology
with every bimolecular rate constant off by that factor —1e12where the
reference network says1.66e-12, caught by diffing
benchmarks/models/bngl/ode/catalysis.bnglagainst a direct BNG2.pl run.
What is dropped is now exactly the verbs that would run the experiment, write
an artifact (a straywriteNETwould land on the network being read), or stop
BNG2.pl early — plus everything after the source's owngenerate_network,
which is where the protocol begins. That call'smax_iter/max_agg/
max_stoichare forwarded: they are what make an unbounded rule set finite, so
regenerating with BNG2.pl's defaults would silently give a different model (or
never return).from_bngl(..., protocol=True)r...
bngsim 0.13.0
pip install bngsim==0.13.0
Added
-
bngsim.HAS_LAPACK_DENSE, and a Linux CI leg that actually runs the BLAS
dense solver (issue #269). The optionaldgetrfdense factor (GH #84) had
no supported capability probe and, after #265, no Linux coverage at all.The probe first, because the CI guard needed it: the flag existed only as
bngsim._bngsim_core.HAS_LAPACK_DENSE, so the one test file that gated on it
reached into the private extension module with agetattrdefault. It is now
bngsim.HAS_LAPACK_DENSE, in__all__, mirrored by
capabilities()["features"]["lapack_dense"]with amissingentry that names
a concrete rebuild path.Falsecosts speed on large dense Jacobians and
nothing else — the built-in LU is the default on every platform and the BLAS
path is opt-in viaBNGSIM_LAPACK_DENSE=1— so the flag answers "will that
variable do anything here?". Of the published wheels only the macOS ones say
yes; the manylinux and Windows legs resolve no LAPACK. The population this
backend actually reaches is source installs on a LAPACK-equipped Linux box.That population had never run a CI job.
python-tests.ymlbuilt the backend
on Linux only as a side effect oflibsuitesparse-dev, which #265 removed on
purpose (it is what makes that leg the bare host an sdist install lands on),
andnative-tests.ymlbuilds on a stockubuntu-latestwhere
find_package(LAPACK QUIET)finds nothing. Two new legs, neither touching the
bare-host proof:native-tests.ymlgains ablas: liblapackmatrix entry. It configures
-DBNGSIM_ENABLE_KLU=OFF, so it never reaches the SuiteSparse autobuild and
cannot weaken #178 by construction. The stock entry stays — that is the
configuration the manylinux and Windows wheels ship.python-tests.ymlgains a third leg,ubuntu-latest+liblapack-devand
nothing else. Still no SuiteSparse, so it still exercises
BNGSIM_KLU_AUTOBUILD. The bare leg is untouched.
Both assert the backend they expect, in both directions: a leg that quietly
loses its BLAS fails, and so does a bare leg that quietly gains one. -
An absolute tolerance that follows the trajectory:
atol="tracking"/
bngsim.TrackingAtol(issue #213). #196 gave the state axis a per-species
atoland removed the cross-species compromise. It left the within-species,
over-time half untouched: whatever number speciesigets, it keeps for the
whole run, so a species that starts at order one and decays to something tiny
outgrows its own tolerance partway through and stops being error-controlled
from there on. CVODE's construct for that isCVodeWFtolerances, an
error-weight function evaluated at the state actually being integrated, and
this is that third mode. The rule:atol_i(y) = clamp(rtol*|y_i|, ceiling_i * 10**-decades, ceiling_i)ceilingis the #196 vector ("auto"by default), so the mode is a strict
extension of it: atdecades=0it reduces to that vector exactly, and the
clamp's upper end means tracking is never looser than the vector it was
built from — only tighter, by at mostdecades.New
tests/data/deep_decay.netis the reproducer, and it is built so the
#196 vector is provably a no-op on it: both live species start at exactly
1.0, soatol="auto"derives 1e-8 for every species — the number the scalar
it replaces would have used. One then decays sixteen decades. Worst relative
error in that species against the analyticalexp(-t):mode rel. err. steps scalar atol=1e-81.8e+04 162 atol="auto"(#196 vector)1.8e+04 162 TrackingAtol(decades=3)3.6e+01 275 TrackingAtol(decades=6)9.5e-02 366 TrackingAtol()(12)2.6e-06 605 TrackingAtol(decades=20)9.4e-06 477 The default depth of 12 is where the accuracy stops improving; past it the
limit is roundoff rather than the tolerance, which is why it gets slightly
worse at 16 and 20. The cost is real and is not hidden: roughly 4x the
steps on this model.Opt-in and orthogonal to #196 — a scalar
atolstays onCVodeSStolerances
and a vector onCVodeSVtolerances, unchanged. Accepted bySimulator.run,
run_batch,parameter_scan,compute_all_sensitivities,steady_state,
steady_state_batchandset_tolerances, and frozen once per batch/scan the
same way"auto"is.hasattr(bngsim, "TrackingAtol")is the capability
probe.The sensitivity axis is re-decided rather than left to inherit:
atolS
keeps reading the ceiling, so turning tracking on leaves the sensitivity
tolerances exactly where the same vector would have put them. Re-deriving
atolSfrom the live state would make that base tighten mid-run, which is
the step-controller hazard the issue #183 high-water mark exists to avoid.
Measured on the same fixture, that costs nothing: the sensitivity column's
worst relative error against-t exp(-t)goes from 2.4e+02 to 1.8e-06 under
tracking anyway, because the state axis is what drives the step size.What it costs on real models, swept over the first 400
rr_paritySBML
models (391 of which integrate at the default tolerance overt = 0..100).
37 of those 391 have a species that falls six or more decades below its own
initial value, so the mechanism is not exotic:arm integrates lost gained steps vs default default (control) 391 — — 1.00 decades=3393 0 2 1.18 decades=6392 1 2 1.24 decades=12387 6 2 1.58 Six models that integrate at the default tolerance do not at the default
depth — a species whose value is a difference of large fluxes cannot be
resolved below its own roundoff, and the step size collapses instead. Those
failures are loud, not silent, and a solver failure under tracking now names
the depth and suggests lowering it, because CVODE's own report ("made no
progress",flag=-3) mentions nothing about the tolerance mode that caused
it. Two models go the other way and integrate only under tracking.The control arm is the important row: the same 391 models, run with the
default scalaratolbefore and after this change, produce 391/391
byte-identical trajectories with identical step counts. Nothing moves
unless it is asked to.On
Bruno_JExpBot2016, which #213 cites as the measured case, the
premise does not reproduce. The issue reports the objective moving 1.3e-05
betweenatol=1e-8and "a converged tail near 1e-16". There is no tail:
reconstructing the shipped job's six conditions and sweeping both axes, the
worst deviation of any observable from artol=1e-12, atol=1e-16reference
is 7.3e-08 atatol=1e-8and 2.4e-08 atatol=1e-16— both at thertol=1e-8
truncation floor, which is where the sequence stops. Droprtolto 1e-10 and
the whole atol axis below 1e-10 collapses onto 5e-10. The reason is
structural: every live species in that model stays between about 0.2 and 6.4
for the whole run, so none of them ever goes under its ownatol. Tracking
moves it by 7.9e-08, i.e. into the same band — which is the right answer for a
model that has nothing for it to fix. The mechanism is real; that model is not
an instance of it, andtests/data/deep_decay.netis. -
The per-species
atolderivation a caller needs is now public (issue
#212). #196 shipped the capability and exported the wrong half of it.
Simulator.auto_atol()— which derives from the model's live state — was
public; the statelessderive_atol(state, rtol, floor=...), which derives
from a state you hand it, was in the privatebngsim._atol, along with the
AUTOtoken. That is backwards for the primary consumer. A parameter fit has
to derive from the model's nominal state and hold the vector constant: a
tolerance that moved with a fitted initial condition would put a step in the
objective everywhere the derivation crossed a rounding boundary, and it fails
invisibly — the objective still looks correct, the finite-difference gradient
check still passes, and only the search behaves oddly. So the function a
fitting frontend needs was the one it could not reach, and the one it could
reach was the one that would break it.bngsim.derive_atol,
bngsim.normalize_atol_vectorandbngsim.AUTOare now exported from the
package namespace and listed in__all__.hasattr(bngsim, "AUTO")is consequently a working capability probe for the
whole feature. It previously returnedFalseon a build that has the
capability, which is the worst failure mode a probe has — it silently routes a
capable install down the scalar fallback and the result still looks right. The
version string is not a substitute: the checkout that first carried #196 still
declared0.12.2, the version of the wheel 25 commits behind it.normalize_atol_vectorwent public rather than staying private on a narrower
argument: everyatol=entry point already runs a vector through it, so
calling it yourself changes only when the error arrives — and for a caller
that assembles its own vector (per-species clamping, a table, a nominal
state), taking the length-and-position check once at setup beats taking it at
the firstrun()of a fit.is_scalar_atoland theAtolLikealias stay
internal.No behavior changed and nothing was renamed; these are re-exports of the same
objects. The docs gained the distinction that motivated all of it: which state
each derivation reads, and the recipe for holding one constant across a fit
(docs/user-guide/solvers.md, "Which st...
bngsim 0.12.2
pip install bngsim==0.12.2
Added
-
Model.effective_ic_sensitivity()— the∂x(0)/∂θtheparameteraxis
already carries (issue #155).output_sensitivities(axis="parameter")is a
total derivative: it carries the right-hand-side path and the
initial-condition seeding.axis="ic"is the companion basis∂y(t)/∂x_k(0)
with the initial value held independent. The two are therefore not
orthogonal —d_param[θ] = (right-hand-side path) + Σ_k (∂x_k(0)/∂θ)·d_ic[x_k]— so a consumer that routes a fitted parameter to every native column it
reaches and sums them double-counts any seeded initial condition. That was
always the contract; nothing about the numbers changes here. What changes is
that it is now stated, and answerable in code rather than by measurement.The reader is paired with the existing writer
declare_ic_sensitivity, and
answers from model structure alone — parameter graph, live initial
conditions, declarations — with no integration, so a fitting frontend builds
its gradient routing once at setup instead of burning a throwaway run or
re-deriving per evaluation. It reports the effective matrix: after the issue
#113 retirement of a species moved off its declared initial condition, and
after the issue #111 declaration overlay. Keys are the ids
sensitivity_params=accepts — a compound<initialAssignment>lowered by
issue #147 reports the original symbols, never the synthetic
_ic_<species>carrier.Present-and-zero is not absent. A present entry valued
0.0means
"seeded, coefficient zero at this state" —∂(a*R0)/∂R0isa, which
vanishes ata = 0without the seeding path ceasing to exist — while an
absent entry means there is no seeding path at all and the caller's own
initial-condition term is the missing piece. Only the second is a signal to
add anic-axis term. Making that distinction visible required separating
structural reachability from numeric value in the seed derivation: the
shared derived-parameter DAG walk drops numeric zeros (it is also the
switch-time scan's "not a parameter threshold" signal), so
compute_ic_param_sens_seednow recovers presence from the token closure and
emits a zero-valued row. The solver still receives no zero rows — they seed
nothing — so no gradient changes.Result.ic_sensitivity_seedcarries the same matrix as the per-run record,
for the cases the model-level reader structurally cannot answer: a batch or
scan over a nonlinear derived initial condition (Rtot = R0*scale) has
point-dependent coefficients, so each row stamps its own,squeezekeeps the
matrix only if every row agreed, andcompute_all_sensitivitiestakes the
union over parameter chunks. It reportsNone— not recorded, distinct from
{}— for acarry_sensitivities=Truephase, where the engine seeds from the
prior phase'sdx/dθand discards these rows entirely (#210/#81); reporting
the computed-but-unused rows there would have been the same silent-wrong-answer
class this whole property exists to end.Both readers and the solver seeding share one derivation
(Model._ic_sensitivity_triples), so a reported matrix cannot drift from the
seed it describes.Also: a
capabilities()["features"]["effective_ic_sensitivity"]flag to gate
on instead of a version string (a build without the reader cannot say what the
seed carries, and every answer a consumer could guess is silently wrong, so
refusing a gradient fit is the honest behaviour); the contract written into the
output_sensitivitiesdocstring,docs/reference/api.mdand the PyBNF
integration guide; and a note thatSteadyStateResultraises no such question
(itsparameteraxis is the implicit-function derivativeJ·∂x*/∂p = −∂f/∂p,
with no seeding term, andaxis="ic"raises).
Fixed
-
Forward sensitivity refused two state-switch crossings at one instant, but
on the corpus they are always one crossing written twice (issue #153). Issue
#150 registers one CVODE root per switch condition and deduplicates them by
the residual's text, which mergesX<1withX<=1and nothing else. Two
spellings of ONE crossing therefore arrive as two roots that fire together,
and the batch was refused outright — each jump readsfon the two branches
of its own condition, and one step across a shared crossing cannot separate
them. The reasoning is right for genuinely independent switches; it is just
not what reaches it. Both models that hit the refusal write one crossing
twice:sp_fourier_synthesizerroots onds1and on3·ds1 − 12·s1²·ds1
(five residuals in all, every one a multiple ofCos1 − amp_offset, all
crossing att = π/2), andml_hopfieldondS1/dtanddS3/dt, which are
identically equal along the trajectory because its own weight matrix leaves
S1 ≡ S3invariant. One is visible in the text and one is not, which is why
the decision belongs at the crossing rather than in the dedup.The batch is now carried into the jump together and the two halves of the
saltation term are checked separately, at the crossing:f⁻ − f⁺has to carry every branch change, which the one flow probe
does exactly when it crosses every residual in the batch — so theδt
ladder now grows until they all flip together, and a batch that never
does is several conditions meeting by coincidence rather than one crossing.dt*/dθhas to be one vector, and flipping together does not
establish that. A common factor does:hcancels out of the
implicit-function ratio, scaling numerator and denominator alike where the
residual vanishes. An equality that holds only along the trajectory does
not —ml_hopfield's two residuals have non-parallel gradients (cosine
−0.30 at the crossing) and theirdt*/dθare permutations of each other
under the model'sW12 ↔ W23symmetry, so a perturbation that breaks it
splits the crossing in two. The vector is therefore formed from each
residual in turn and compared, and a batch that disagrees is refused with
the numbers.
The second is the criterion, and it is deliberately weaker than "one
surface": what the jump needs is a singlet*(θ)to shift the flow along,
which two independent crossings also have when the requested columns move
them together. A fixture of two unrelated species decaying through a shared
threshold merges exactly (its∂W/∂cis the closed-form −8 to eight digits)
for the threshold column and is refused for the column that splits the pair.Order matters and is the cheap way round: the branch gap is measured before
anydt*/dθ, and both corpus models are the BNGL signed-rate idiom
(if(r>0, r, 0)againstif(r<0, −r, 0), continuous wherer = 0), so they
return with no jump at all — and never reach the second check. Their columns
come from the in-branch sensitivity RHS and match a finite difference of the
model's own trajectory.Corpus A/B on the
netcondarm — the 80 of 585.netmodels whose text
carries anif(), under forward sensitivities, against the issue #150 binary:
76 identical, 2 newly answered, 1 wall-clock capped, 1 identical refusal.
Nothing moved: a single-switch crossing takes the same path it did, and the
two newly answered rows are exactlyml_hopfieldandsp_fourier_synthesizer.
The remaining refusal isml_q_learning, whose residual is not
differentiable across its own surface (issue #154). -
Forward sensitivity through a state-dependent switch in a rate law missed
the saltation jump at its crossing (issue #150). A condition that reads the
state —piecewise(0, Virus < 1, Virus*rho_V)— flips a branch offat a
crossing whose timet*(θ)moves with every parameter through the
trajectory. The in-branch derivative was never the problem:sympy.diffof
thePiecewiseis right on both sides and carries no boundary delta. What is
discontinuous is∂x/∂θitself, by the saltation terms(t*⁺) = s(t*⁻) + (f⁻ − f⁺)·dt*/dθand neither way bngsim could produce a sensitivity RHS carried it — the
analytic one differentiates each branch where it is live, CVODES' internal
difference quotient integrates the variational equation straight across. GH
#68 declined the analytic path here and issue #146 corrected the warning to
say the fallback was wrong too; this is the fix.Three pieces, all of them the rate-law twin of something issue #144 already
built for a state-dependent event trigger: split the condition into a
residuallhs − rhs(NetworkModel::state_switch, cached per condition and
re-derived byclone()— an expression id means something else in another
evaluator); register that residual as a CVODE root, so the crossing is
located rather than chased; and at the root, readfon both branches at
x(t*), formdt*/dθby the implicit function theorem, and jump. The
∂t*/∂θ solve is now one function (Impl::residual_dtstar) reached from both
the event trigger and the rate law, transversality floor included.The branch selection is the one genuinely new mechanic. A clock switch picks
its branch by nudging the clock across its threshold; a state switch has no
such variable, so the whole state is nudged along the flow,x^± = x(t*) ± δt·f, which for a unit-rate counter reduces to the clock nudge exactly.δt
is measured, not assumed: CVODE locates a root only to ~100·ε·(|t|+|h|), so
the size starts just past that and grows until the residual actually changes
sign across the pair. Failing to flip inside the ladder is refused rather than
answered with a jump of zero — "we could not tell the branches apart" and "the
branches agree" are otherwise indistinguishable. The ver...