Skip to content

network-free: start NFsim and RuleMonkey from the model's species amounts (#1373) - #1385

Merged
wshlavacek merged 3 commits into
lanl:mainfrom
Montana:fix/1373-nf-rm-set-concentration
Oct 31, 2026
Merged

wshlavacek merged 3 commits into
lanl:mainfrom
Montana:fix/1373-nf-rm-set-concentration

Conversation

@Montana

@Montana Montana commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Closes #1373.

Stacked on #1372 (#1360, parameters), whose fixture and run wiring this extends. Until #1372 merges, this diff also shows its commit; the change here is the second commit.

What was wrong

Model.set_concentration never reached NFsim or RuleMonkey. Both are seeded from the BNG XML's <Species> list, so a run started from the XML's amounts, whatever the model held. The issue's repro (model_parameters, L(r) set to 0) gives Lfree(t=0) = 300 on nf and rm, and 0 on SSA, with no warning. The same holds for a write made after the Simulator is built, and for the end state an SSA or ODE run leaves in the model.

The fix

The species-side twin of #1372, built the same way.

  • Python (_network_free_params.py, _simulator.py, _model.py):
    • The model records every species' amount at load (Model._loaded_state), and a clone keeps its parent's.
    • Each nf/rm run hands the backend every species the model now holds at another amount, keyed by the <Species id=> whose name= is that species' .net name. BNG2.pl writes the same canonical string in both, $ and @c:: included.
    • The amounts are handed over afresh at each run, so an amount set back to its loaded value runs as the XML has it again.
    • A nonzero amount the XML has no seed species for, or a name two seed species share, raises ValueError before the run. The backend would start that species at 0.
  • C++: set_species_amounts({id: amount}) on both wrappers. It replaces the whole map and is folded into the seed-rounding step each wrapper already has. Each amount is rounded half-up like every seed (GH Codegen cache does not invalidate on sensitivity-RHS changes (#41 and #43 were silently inert on warm caches) #51) and outranks a parameter the species' concentration= reads, as Model.set_concentration does on the network.
    • NFsim: written into the XML that create_system already rewrites (seed_count_rounding.hpp). That rewrite is fail-safe and loads the original on any error, so it now reports which ids it wrote, and create_system throws if any is missing. Otherwise the fail-safe would bring back the silent wrong start.
    • RuleMonkey: laid down as set_initial_amount pins in apply_seed_rounding. That function clears every pin at each set_param, so a pin set any other way would be lost. A refused id leaves the previous pins in place.

Tests (test_network_free_species_amounts.py, 27: nf and rm each, one without a backend)

test before the fix
the issue's repro: Lfree(0) = 0 fail (300)
Lfree = 50 and RP = 40 (the second has a literal 0 in the XML) fail
every species, complexes included, starts as SSA does fail
49.5 seeds 50 and 49.4 seeds 49 fail
set after the Simulator is built, then set back between runs fail
L(r) outranks L0, and L0 alone still seeds it (#1372) fail
a clone keeps what its parent loaded fail
starts from an SSA run's end state fail
a nonzero amount for a species the XML does not seed is refused fail (ran from 0)
the backend refuses an id it has no seed species for fail
controls: an unchanged model hands over nothing and runs bit-identically; 0 for an unseeded species runs; a name two seed species share is refused pass

Mutations

mutant fails
M1: no amounts handed over (the bug) 22: all but the five controls
M7: unchanged amounts handed over too the unchanged-model control
M8: a clone forgets what its parent loaded the clone test
M9: 0 for an unseeded species refused its control
M10: a shared name takes the last id the shared-name test
C1: NFsim ignores the amounts 9, every nf case (and #1372's L0=0 case, whose amount now arrives as a species amount)
C2: NFsim does not check every id was written the backend-refusal test, nf
C3: RuleMonkey lays no pins 9, every rm case
C4 / C5: truncate instead of rounding half-up (nf / rm) the 49.5 case
C6: RuleMonkey's setter does not re-pin 9, every rm case

Measured

Behaviour that changes

  • A network-free run on a model that an SSA or ODE run has already moved now starts from the moved state, as a second SSA run would, instead of from the XML.
  • Where that state holds a complex the XML does not seed, the run is refused rather than started from the XML. That is the case the issue asked to raise on.

…unts (lanl#1373)

Model.set_concentration never reached either backend: both are seeded from
the XML's <Species> list, so with L(r) set to 0 a run started from the XML's
300. Each run now hands the backend every species the model holds at an
amount other than its amount at load, by the XML seed species of the same
name, through a new set_species_amounts on both wrappers. Amounts round
half-up like every seed and outrank a parameter the seed reads. NFsim writes
them into the XML it already rewrites, and refuses an id it could not write;
RuleMonkey lays them as pins in apply_seed_rounding. A nonzero amount for a
species the XML does not seed is refused.
…r a seed parameter (lanl#1373)

Review of lanl#1385. Three ways a run still started somewhere other than the
model, each reproduced on the PR's build against SSA:

- A fixed ($) seed species was never matched. The .net loader drops the `$`
  from the name (net_file_loader.cpp) and the XML keeps it (`$X(a)`), so with
  fixed_seed.{net,xml}: X(a) set to 10 was refused as "no seed species of that
  name", X(a) set to 0 was dropped as a non-seed species and nf and rm started
  from the XML's 50, and even set_param("X0", 75) alone was refused, since the
  model re-derives X(a) from it. xml_seed_species now reads names the way the
  loader does (`$` at the start or after `@c::`).

- A species at its amount at load was not handed on while a parameter its seed
  reads was. After save_concentrations(), set_param("L0", 0) leaves L(r) at 300
  and SSA starts there; nf and rm were handed L0 = 0 alone, re-derived the seed,
  and started Lfree at 0. Wherever a parameter is handed on, every seed species
  now goes with it at the model's amount (species_amounts(every_seed=...)). On
  58 runs of the tests/data .net/.xml pairs after a parameter write, the
  trajectories are bit-identical to the PR's.

- A -1e-12 amount, which an ODE run can leave, failed both backends ("could not
  seed species S1", "must be a finite, non-negative molecule count") where SSA
  rounds it to 0. An amount above -0.5 now goes as at least 0; one that rounds
  to no count is refused by name.

Also: an XML ElementTree cannot read now refuses a changed amount (ModelError),
as lanl#1372's review made it refuse a changed parameter, where a 0 was dropped. The
user guide still said set_concentration "is not passed on yet (issue lanl#1373)", and
"an earlier run" read as though a network-free run moved the model; it does not.

11 new tests, all failing on the PR's Python. Mutations: no `$` strip (5 fail),
no `@c::$` strip (1), every_seed off or ignored (2 each), no clamp (2), {} on a
parse failure (1), no count check (1).
@wshlavacek
wshlavacek merged commit 9bc55fb into lanl:main Oct 31, 2026
23 checks passed
wshlavacek added a commit to Montana/bngsim that referenced this pull request Oct 31, 2026
… $, and say how to undo a moved model

Two rough edges left after lanl#1385 (lanl#1373).

A fixed species could not be named by its .net name. The .net loader drops
the `$` (`$A()` is the model's `A()`, `@CP::$Sink()` its `@CP::Sink()`,
net_file_loader.cpp), so `Model.set_concentration("$A()", 7)` raised "Species
'$A()' not found in model". BNG2.pl's setConcentration takes either spelling,
on a fixed species or not (`$B()` sets a plain `B()`), checked with BNG 2.9.3.
`set_concentration`, `get_concentration` and `declare_ic_sensitivity` now try a
name that is no species again without its `$`, at the start or after `@c::`,
and keep the model's name for the bookkeeping keyed by it (`_declared_ic_sens`,
`_ic_write_log`). The `$` stripper moves to `_model.py`, which
`_network_free_params` already imports from. The parity replay's
`resolve_species_name` drops the `$` the same way, where a
`setConcentration("$X()")` sent a multi-segment replay to its single-segment
fallback; no corpus protocol has one yet.

A network-free run refused after an SSA, PSA or ODE run on the same model gave
the wrong advice. That run writes its end state into the model, so the next nf
or rm run starts there (lanl#1373), and a complex an XML written without the
network does not seed is refused. The message said to rewrite the XML or run
SSA. It now names that run, `model.clone()` for it, and `model.reset()`, which
the new test shows lets the run go on. The user guide says the same.

Tests fail on main: 8 (the `$` names in set/get/declare, under a compartment
prefix, the declared row kept under the model's name, the refusal's advice on
nf and rm, the parity resolver). Mutations: no `$` retry (3 fail), bookkeeping
under the given name (1), the old advice (2), no exact `$`-less match in the
resolver (1, a complex), its key taken on the `$` name (1), declare exact only
(1). Full suite before the last review round: 17,278 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

method='nf'/'rm' silently ignore Model.set_concentration: the run starts from the XML's seed amounts

2 participants