Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1358.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **After a compiled ODE run, `get_param` on a parameter an assignment rule sets returned its value at load** ([#1358](https://github.com/lanl/bngsim/issues/1358)). This was on the warm path, a run with no events and no sensitivities. The interpreter writes such a parameter at every right-hand-side call, but the compiled right-hand side (cc or MIR) computes the rule in its own C and wrote nothing back: `q := base*A` read 1.0 after the run, where an interpreted run read 0.0183. That path now leaves the model's functions, and what they set, at the state the run ends on, as the general path already did. The trajectory was never affected.
18 changes: 18 additions & 0 deletions python/tests/test_codegen_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ def test_codegen_engages_for_forward_reference_model(monkeypatch):
assert getattr(m, "_codegen_so_path", "") or getattr(m, "_codegen_c_source", "")


def test_a_compiled_run_leaves_the_rule_set_parameters_at_its_end(monkeypatch):
"""The compiled output evaluator fills the rows without writing the model,
and the compiled RHS writes no parameter, so after the run ``get_param`` on
a parameter an assignment rule sets read its value at load (issue #1358).
``b := 0.5·S`` and ``a := b``, so both read 0.5·S(4) = 5·e^-2, not the
5.0 they hold at load.

The mir-jit leg runs this file under BNGSIM_CODEGEN_JIT=mir, so this is the
MIR backend's check as well as cc's."""
monkeypatch.delenv("BNGSIM_NO_CODEGEN", raising=False)
m = bngsim.Model.from_sbml_string(_decay_with_rules_sbml())
sim = bngsim.Simulator(m, method="ode", codegen=True)
r = sim.run(t_span=(0.0, 4.0), n_points=21, rtol=1e-9, atol=1e-12)
s_end = float(np.asarray(r.species)[-1, list(r.species_names).index("S")])
assert m.get_param("b") == pytest.approx(0.5 * s_end, rel=1e-12)
assert m.get_param("a") == m.get_param("b")


# ─── Source-emitter gating ───────────────────────────────────────────────────


Expand Down
53 changes: 53 additions & 0 deletions python/tests/test_sbml_initial_assignment_param_lift.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from __future__ import annotations

import logging
import math

import bngsim
import numpy as np
Expand Down Expand Up @@ -362,3 +363,55 @@ def test_a_write_after_a_run_does_not_move_it_to_the_rules_current_value():
assert m.get_param("q") != pytest.approx(K0_AT_LOAD / 2), "precondition: the rule moved"
m.set_param("base", m.get_param("base"))
assert m.get_param("k0") == K0_AT_LOAD


# ── What a run leaves in a rule-set parameter (issue #1358) ─────────────────
#
# q := base·A. The interpreter writes q's slot at every right-hand-side call;
# the compiled right-hand side computes the rule in its own C and wrote nothing
# back, so after a compiled run get_param("q") read its value at load, 1.0,
# where the interpreted run read 0.0183. Every run now leaves the slot at the
# rule's value on the state it ends on: base·A(2) = 0.5·2·e^-4 = e^-4.

# The cold path (run()) takes a run with an event or sensitivities; the warm
# fast path (run_warm) one with neither. Both publish the final state.
_EVENT_FAR_AWAY = AR_DEPENDENT.replace(
"</listOfReactions>",
"</listOfReactions>"
'<listOfEvents><event id="late" useValuesFromTriggerTime="true">'
'<trigger initialValue="false" persistent="true"><math '
'xmlns="http://www.w3.org/1998/Math/MathML"><apply><gt/>'
'<csymbol encoding="text" definitionURL="http://www.sbml.org/sbml/symbols/time">t</csymbol>'
"<cn>100</cn></apply></math></trigger>"
'<listOfEventAssignments><eventAssignment variable="B"><math '
'xmlns="http://www.w3.org/1998/Math/MathML"><cn>0</cn></math></eventAssignment>'
"</listOfEventAssignments></event></listOfEvents>",
)
# Without the event the "cold" cases below would run the warm path and pass.
assert "<listOfEvents>" in _EVENT_FAR_AWAY, "fixture text drifted"


@pytest.mark.parametrize(
("xml", "kw"),
[
(AR_DEPENDENT, {}),
(AR_DEPENDENT, {"codegen": True}),
(_EVENT_FAR_AWAY, {}),
(_EVENT_FAR_AWAY, {"codegen": True}),
(AR_DEPENDENT, {"sensitivity_ic": ["A"]}),
(AR_DEPENDENT, {"codegen": True, "sensitivity_ic": ["A"]}),
],
ids=["warm", "warm compiled", "cold", "cold compiled", "sensitivity", "sensitivity compiled"],
)
def test_a_run_leaves_the_rules_value_in_the_parameter(xml, kw):
m = bngsim.Model.from_sbml_string(xml)
r = bngsim.Simulator(m, method="ode", **kw).run(t_span=T_SPAN, n_points=N_POINTS)
a_end = float(np.asarray(r.species)[-1, list(r.species_names).index("A")])
assert m.get_param("q") == pytest.approx(0.5 * a_end, rel=1e-12)
assert m.get_param("q") == pytest.approx(math.exp(-4.0), rel=1e-5)


# The same check under the MIR JIT is
# test_codegen_outputs.py::test_a_compiled_run_leaves_the_rule_set_parameters_at_its_end:
# the mir-jit leg runs that file under BNGSIM_CODEGEN_JIT=mir, and runs this
# one nowhere.
9 changes: 9 additions & 0 deletions src/cvode_simulator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4292,6 +4292,15 @@ Result CvodeSimulator::Impl::run_warm(const TimeSpec &times, const SolverOptions
}
const double final_t = (check_ss && ss_reached) ? t_out[last_recorded_index] : times.t_end;
model.set_current_time(final_t);
// The functions, and the parameters assignment rules set, at the state
// the run ends on, as run() leaves them by its refresh at each row
// (issue #1358). A parameter an assignment rule sets is written by the
// interpreter's evaluate_functions; the compiled right-hand side
// computes the rule in its own func[] and writes nothing back, and the
// rows here read the functions through fill_row without writing them
// either. So after a compiled run get_param read the value at load
// (q 1.0, where an interpreted run read 0.0183).
sync_model_at(final_t, y_data, ns);

// GH #210 — the warm path never computes sensitivities, so it advances
// the state (carry-over) without tracking dx/dθ: mark the state dirty
Expand Down
Loading