Evaluate BNGL parameter expressions instead of dropping them - #673
Conversation
A BNGL parameters block may define a parameter as an expression over other parameters, which is ordinary style rather than an edge case: across the model corpora on hand, 1839 of 8822 parameter declarations (20.8%) are expression-valued, in 156 of 297 files. BnglModel could not evaluate any of them. get_parameter_value raised NotImplementedError, and get_free_parameter_ids_with_values swallowed the ValueError and skipped the parameter, so building a PEtab problem lost parameters with nothing said to the user. Resolving one needs no BNG2.pl and no network generation, because a parameters block is arithmetic over other parameters. Add a small evaluator that walks the definitions in dependency order, so declaration order does not matter, and reports a circular definition by naming the cycle rather than recursing until the stack gives out. The sublanguage is BNGL's, so the expressions are tokenized and parsed rather than passed to eval, which would quietly import Python's meanings: ^ is exponentiation and not bitwise xor, ln is the natural logarithm while log10 and log2 are separate names, and division is floating point. A bare log is rejected rather than assumed to mean ln, so a typo is an error rather than a plausible wrong number. _bngl_expr is stdlib-only and imports nothing from the rest of PyBNF, so it can travel with the adapter to libpetab-python (lanl#591, lanl#420 Step B). This supersedes the confinement the previous behaviour pinned, so the test that asserted the NotImplementedError now asserts the computed value. Fixes lanl#666 Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
…ter costing the block The evaluator's semantics were derived from the issue text rather than from a run, and BNG2.pl 2.9.3 disagrees in three ways that produce a wrong number silently -- the failure class lanl#666 set out to fix. Checked by putting each expression through writeNET({evaluate_expressions=>1}), the only export path that emits numbers instead of echoing the source: -2^2 BNG2.pl 4 was -4 unary minus binds tighter than ^ 2^3^2 BNG2.pl 64 was 512 ^ is left associative, not right rint(2.5) BNG2.pl 3 was 2 rint is floor(x+0.5), not round-half-even The tests asserted the first two, so they would have kept anyone from noticing. Also aligns the built-in table with Expression.pm: _pi/_e are zero-argument functions (_pi(), not _pi); floor/ceil are not BNGL (commented out upstream as unsupported by muParser); asinh/acosh/atanh/sum/avg were missing; and the comparison and logical operators plus if() were unlexable, which is real BNGL that BNG2.pl evaluates. TFUN is deliberately still absent -- it reads a data file at simulation time, so it is not a parameter-block constant. Resolution is now partial. get_free_parameter_ids_with_values() raised for the whole block on a single unusable definition, which lost more than the original bug did: antigen_pulses_harmon2017_simplified.bngl went from 9 parameters to an exception. It now returns everything it can resolve and warns, naming what it could not. Across the 303 corpus models this moves parameter coverage from 79.3% to 99.0% (+1845), with no model raising; the 89 residuals are all PyBNF __FREE placeholders, which correctly have no value until a fit substitutes one. BNG_VERIFIED is the contract, checked from both sides: pinned offline so it holds in ordinary CI, and re-derived from a real BNG2.pl in one run under the existing `bionetgen` marker so it cannot quietly rot. 84 of 86 differential probes agree; the two that do not are documented and both in the permissive direction (BNG2.pl drops a forward-referencing parameter, and accepts a trailing operator). ADR-0026 is annotated where it says an expression RHS is out of scope.
|
Thanks for this. The parser approach is right, and flagging that you could not check against BNG2.pl was the useful part. I ran that check and pushed the results to your branch. BNG2.pl disagreed in three places: So unary minus binds more tightly than the power operator, and the power operator groups from the left. BioNetGen defines rint as floor(x + 0.5), which rounds a half upward, while Python's round sends a half to the nearest even number. Two of the tests asserted the old answers, which would have hidden this later. To reproduce, put this in a file, run BNG2.pl on it, then read the parameters block of ev.net: It prints 4, 64 and 3. Writing the network with evaluate_expressions turned on is the only export path that prints numbers instead of copying the original text back out. I also matched the function table to Perl2/Expression.pm in the BioNetGen source. _pi and _e take no arguments, so they are written _pi() and _e() rather than as bare names. floor and ceil are commented out there as unsupported, so BNG2.pl rejects them. asinh, acosh, atanh, sum and avg were missing. The comparison operators, the logical operators and the if function could not be read at all, and two of our published models use them. I left TFUN out on purpose because it reads a data file while a simulation runs, so it is not a constant. The last change is failure handling. Raising for the whole block on one bad definition loses more than the original bug did. On models/antigen_pulses_harmon2017/antigen_pulses_harmon2017_simplified.bngl the old code returned 9 of 17 parameters and the branch raised instead. get_free_parameter_ids_with_values now returns every parameter it can work out and warns about the rest by name. Across the 303 models with a parameters block in our model collections this moves coverage from 79.3% to 99.0%, an extra 1845 parameters, with no model raising. The 89 still left out are fitting placeholders ending in __FREE, which have no value until a fit supplies one. The table of expressions and expected values is now checked from both sides. It is pinned in the test file so it runs without BioNetGen installed, and a second test rebuilds the same values from a real BNG2.pl when one is on the path. I confirmed that second test can fail by putting the old 512 answer back and watching it report the mismatch. I added notes to architecture decision record 0026 in the four places where it says evaluating an expression is out of scope. You were right to leave that alone in a bug fix. |
Fixes #666.
Adds
pybnf/petab/_bngl_expr.py, a parser and evaluator for the parameters-block sublanguage, and pointsBnglModelat it. The issue's example now resolves:which is
0.1 / (5.0 * 6.022e23 * 1e-12)exactly.On the semantics
Expressions are tokenized and parsed rather than handed to
eval, and the reason is not only injection. Python would silently supply its own meanings for the operators:2^38.0, not1-2^2-4.0(^binds tighter than unary minus)2^3^2512.0(right associative)1/20.5ln(_e)1.0, andlog10(1000)is3.0logon its own is rejected rather than treated asln. BNG2.pl does not accept it, and quietly mapping it would turn a typo into a plausible number instead of an error, which is the failure mode this issue is about.Resolution is lazy and by dependency, so declaration order does not matter (
{'b': 'a*2', 'a': '3'}resolves fine), matching BNG2.pl. A circular definition names the cycle:_bngl_expris stdlib-only and imports nothing else from PyBNF, per the constraint in the issue about moving it to libpetab-python alongside the adapter.A behaviour change to flag
test_expression_valued_parameter_is_not_evaluatedintests/test_petab_export.pypinned the old contract, so it fails against this change. Since #666 asks for exactly that reversal I have updated it to assert the computed value and renamed it, rather than leaving it red. It is worth a maintainer's eye, because the confinement it pinned is described as deliberate in ADR-0026 and the module docstring still says evaluation is out of scope; both probably want a note now, and I did not want to edit an ADR in a bug-fix PR.Testing
23 cases in
tests/test_petab_bngl_expr.py: the issue's example end to end, each semantic above, the real expression shapes quoted in the issue (loop3, aliasing,1/lifetime,10*krZapTcr,ln(Kd_...)), chained dependencies, cycles, and the error paths (unknown name, unknown function, truncated input, division by zero, stray character). Two fail onmainwithNotImplementedError.For the wider suite I compared like for like, since this checkout cannot run everything:
All 29 are pre-existing and want a BNG2.pl I do not have (
PybnfError: The location of the BioNetGen simulator (BNG2.pl) is not specified). Three further modules fail to collect for a missinghypothesis, identically before and after, so they are excluded from both runs above.ruff checkis clean on the new module and both test files under the repo's config.bngl_model.pyreports onePLW1510on the pre-existingsubprocess.runinis_valid, identical before and after.The one thing I could not do is check the evaluator against BNG2.pl on a real model, as the issue suggests for anything uncertain, because there is no BNG2.pl here. The semantics above come from the issue and from BNG2.pl's documented behaviour rather than from a run, so they are worth confirming on your side before this lands.
I used an AI assistant while working on this. The runs quoted are ones I executed.