Skip to content

expression: refuse a bracket after a call, which ExprTk reads as a second statement (#1415) - #1419

Merged
wshlavacek merged 2 commits into
lanl:mainfrom
Montana:fix/1415-bracket-after-call
Nov 1, 2026
Merged

wshlavacek merged 2 commits into
lanl:mainfrom
Montana:fix/1415-bracket-after-call

Conversation

@Montana

@Montana Montana commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1415.

What was wrong

ExprTk multiplies a variable, and a bracketed group, against a bracket after it: A(B) and (A)(B) are A·B. After a function call it does not. The bracket opens a second statement, and the expression's value is the last statement's. So the call, and everything before it, was dropped with no word.

A .net with A = 3, B = 5, f() read through the right-hand side, on main:

expression main the product
k*exp(-A)(B) (k = 0.5) 5 0.124
sin(A)(B) 5 0.706
2*abs(A)(B) + 1 6 31
time()(B), rint(A)(B), if(A>1,1,0)(B), sin(A)[B], sin(A) (B), sin (A)(B), tfun(...)(B) 5
k*(1 + exp(-A)(B)) refused with ExprTk's bare ERR244 - Expected ')'

BNG2.pl rejects the same text (ABORT: Syntax error at (Bobs)), so a network BioNetGen writes never holds one.

The change

ExprTkEvaluator::compile refuses an expression where a call's closing ) is followed by (, [ or {, and names the call:

Expression 'kexp(-A)(B)': the bracket after the call 'exp(-A)' is not multiplied by it. ExprTk reads it as a second statement, and the expression's value would be that statement's alone, which drops the call and everything before it (issue #1415). BioNetGen rejects it as a syntax error. Write the product with '': 'exp(-A)*(...)'.

The issue offered refusing or reading the product. I chose to refuse because BioNetGen does, and because a text that silently dropped half its value is better fixed by its author than guessed at.

The diff (src/expression.cpp, abridged)

@@ class ExprTkEvaluator::Impl
+    // Issue #1415: the first call in `expr` that a bracket follows, as the
+    // call's text, or "" where there is none. [...]
+    std::string call_before_bracket(const std::string &expr) const {
+        [...]  // numbers are skipped whole, exponent included: `1e1(B)` is a product
+            const std::string name = expr.substr(start, i - start);
+            [...]  // spaces skipped: `sin (A)` is a call
+            static const std::unordered_set<std::string> kBinaryWords = {
+                "and", "or", "xor", "nand", "nor", "xnor", "mand", "mor", "in", "like", "ilike"};
+            if (open >= n || expr[open] != '(' || symbol_table.is_variable(name) ||
+                symbol_table.is_constant_node(name) || kBinaryWords.count(name) != 0) {
+                continue;  // a product, as ExprTk reads it: `A(B)`, `k(B)`, `(x) and (B)(C)`
+            }
+            [...]  // `close` is the call's matching ')'; spaces skipped after it
+            if (next < n && (expr[next] == '(' || expr[next] == '[' || expr[next] == '{')) {
+                return expr.substr(start, close + 1 - start);
+            }
+        }
+        return "";
+    }
@@ int ExprTkEvaluator::compile(const std::string &expr) {
     preprocessed = expr_compat::exact_literals(preprocessed);
 
+    // Issue #1415: refused, where ExprTk would keep only the bracket after it.
+    const std::string call = impl_->call_before_bracket(preprocessed);
+    if (!call.empty()) {
+        throw std::runtime_error(
+            "Expression '" + expr + "': the bracket after the call '" + call +
+            "' is not multiplied by it. ExprTk reads it as a second statement, [...] "
+            "(issue #1415). BioNetGen rejects it as a syntax error. Write "
+            "the product with '*': '" + call + "*(...)'.");
+    }
+
     // Delegate to compile_preprocessed (which also caches the string)
     return compile_preprocessed(preprocessed);

Tests

test_codegen_exprtk_functions.py (#1348's) is updated where it asserted the old reading. test_a_bracket_after_a_call_declines now expects the interpreter to refuse (it asserted B), and still checks codegen's own decline on the text. The table case becomes test_a_bracket_after_a_table_is_refused. exp leaves _NO_C, since that law no longer loads.

python/tests/test_bracket_after_a_call.py (25 tests):

  • 11 refusals, each naming the call: every row above, plus sin (A)(B), an inline table (the loader writes it as tfun_f__tfun0(), which is named), and the bracketed k*(1 + exp(-A)(B)). All 11 fail on main.
  • A function body given after load: refused the same way.
  • 14 controls held to their values (they pass on main too), including (A > 1) and (B)(A) = 1: A(B) = 15, (A)(B), A (B), 2(B) = 10, k(B) = 2.5, k*exp(-A)*(B), exp(-A)+(B), sin(A), 1e-1(B), 1e1(B) = 50, A(B)(A) = 45, k(B)(A) = 7.5, 1e1(B)(A) = 150.

Mutation tests (the core rebuilt for each):

mutant result
never refuse killed (the refusal tests)
a variable counted as a call killed (A(B)(A))
an exponent read as a name killed (1e1(B)(A))
( only, not [ killed (sin(A)[B])
no space skipped before the bracket killed (sin(A) (B))
no space skipped before the arguments killed (sin (A)(B))
a word operator counted as a call killed ((A > 1) and (B)(A))
a constant counted as a call survives: equivalent today. Nothing registers a constant (define_constant has no caller); parameters are variables. The check stays for the API.

Corpus

1,323 BioModels plus the repository's 177 .net files: 1,500 loads, identical on main and here (1,468 load, and the same 32 fail on both). No model is written this way.

Checks (macOS arm64)

  • python/tests: 17,204 passed, 0 failed.
  • parity_checks/tests: 1,015 passed. The 2 failures (test_bngsim_provenance and the reference-archive replay) also fail on main here: their subprocess can't import bngsim without my local import shim.

pre-commit run --all-files, sphinx -W, and ci/changelog.py check/required (changelog.d/1415.fixed.md) are clean.

Montana and others added 2 commits October 30, 2026 15:06
…cond statement (lanl#1415)

ExprTk multiplies a variable or a bracketed group against a bracket
after it, but after a call the bracket opened a second statement, and
the expression's value was that statement's: k*exp(-A)(B) gave B, and a
.net rate law written so ran as its last factor with no word. compile()
now refuses such an expression, naming the call, as BNG2.pl rejects it
as a syntax error. A(B), (A)(B), 2(B) and a word operator before a
bracket are read as before. No corpus or repository model is written so.
The scanner took any name that no variable held, and no word operator on
a list, for a call. Two of the words were wrong: mand and mor are ExprTk
calls, so k*mand(A > 1, B > 1)(B) still loaded and gave B. Its own token
rules were wrong too: it split k.g at the dot, so a model with a
parameter k.g had k.g(A)(B), the product, refused as a call to g. And an
undefined name was refused as a call, with advice to write foo(A)*(...),
which fails just the same.

A call is now what ExprTk's parse_symbol calls: a built-in function, if,
not, or a function in the symbol table. Names and numbers are read with
the lexer rules exact_literals already follows (number_token_end, and
symbol_token_end, factored out of it), in one pass. The scan reads the
text as written, so the refusal quotes it: if(A>1 && B>1,1,0) rather
than the engine's "if(A>1  and  B>1,1,0)".

That needed exact_literals fixed first: it respells a literal ExprTk
misreads as a bracketed quotient, which after a call opened the same
second statement. abs(B)0.49999999999999997, a product to ExprTk's lexer,
gave the literal alone on main, and the PR refused it, naming a bracket
nobody wrote. The respelling now writes the '*' the lexer would read.

The check moves to expr_compat, the place both ExprTk layers share, and
NFsim's function parser runs it too: a hand-written XML function
3*exp(0)(2) gave 2, for 6.

Every new test fails on the PR's head.
@wshlavacek
wshlavacek merged commit b37fa08 into lanl:main Nov 1, 2026
22 checks passed
wshlavacek added a commit that referenced this pull request Nov 2, 2026
nfsim: carry #1419's shim check, and run the vendor tripwire in CI
wshlavacek added a commit to Montana/bngsim that referenced this pull request Nov 2, 2026
lanl#1419 added the bracket-after-call refusal to NFsim's mu::Parser shim
(nfsim_funcparser.h) as a direct edit to third_party/nfsim/. A refresh
replaces that tree with what upstream plus scripts/nfsim_vendor_patches/
produce, so the next one would have dropped the check without a word:
the 20-patch candidate's export differs from main's tree in that file
alone. It is now carry 0021, bngsim/carry-shim-bracket-after-call, and
the re-vendored tree is byte-identical to main's.

It got in because nothing ran the tripwire. NFSIM_VENDORING.md said a
paused vendor_verify job in build.yml would, but no such job exists; the
check ran only in a manual pre-commit stage. lint.yml gains an
nfsim-vendor job that replays the patch series onto the base VENDOR.json
records and runs vendor_nfsim.py --verify-clean, about a minute. On
main's tree it fails, naming nfsim_funcparser.h.
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.

The interpreter reads a bracket after a call as a second statement: k*exp(-A)(B) is B, and k*exp(-A) is dropped without a word

2 participants